419 lines
13 KiB
Lua
419 lines
13 KiB
Lua
-- Prize Wheel — spinning prize wheel, top-down view
|
||
-- Tom's Peripherals GPU + screen wall.
|
||
--
|
||
-- The wheel fills the screen as a circle (top-down, like roulette).
|
||
-- It physically rotates: angular velocity starts high and decays under
|
||
-- friction. A fixed pointer arrow at the top selects the prize.
|
||
--
|
||
-- The wheel IS the thing that spins — wedges rotate each frame.
|
||
-- Centre hub shows the current prize name when stopped.
|
||
|
||
----------------------------------------------------------------------
|
||
-- GPU discovery
|
||
----------------------------------------------------------------------
|
||
|
||
local function findGPU()
|
||
print("[prizewheel] Scanning peripherals...")
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
local t = peripheral.getType(name)
|
||
print(" " .. name .. " = " .. tostring(t))
|
||
if t and t:find("gpu") then
|
||
print("[prizewheel] Using GPU: " .. name)
|
||
return peripheral.wrap(name)
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Constants
|
||
----------------------------------------------------------------------
|
||
|
||
local FRAME_DELAY = 0.033
|
||
local TWO_PI = math.pi * 2
|
||
|
||
-- Prize segments — name, colour, relative weight
|
||
local PRIZES = {
|
||
{ name = "$100", col = 0xF44336, weight = 2 },
|
||
{ name = "SPIN AGAIN", col = 0xFFEB3B, weight = 3 },
|
||
{ name = "$500", col = 0x4CAF50, weight = 1 },
|
||
{ name = "BANKRUPT", col = 0x212121, weight = 2 },
|
||
{ name = "$250", col = 0x2196F3, weight = 2 },
|
||
{ name = "$50", col = 0xFF9800, weight = 3 },
|
||
{ name = "JACKPOT", col = 0xE91E63, weight = 1 },
|
||
{ name = "$150", col = 0x9C27B0, weight = 2 },
|
||
{ name = "LOSE TURN", col = 0x607D8B, weight = 2 },
|
||
{ name = "$75", col = 0x00BCD4, weight = 3 },
|
||
{ name = "$1000", col = 0x8BC34A, weight = 1 },
|
||
{ name = "$25", col = 0xFF5722, weight = 3 },
|
||
}
|
||
|
||
local function buildSegments()
|
||
local total = 0
|
||
for _, p in ipairs(PRIZES) do total = total + p.weight end
|
||
local segs = {}
|
||
local cum = 0
|
||
for i, p in ipairs(PRIZES) do
|
||
local arc = (p.weight / total) * TWO_PI
|
||
segs[i] = {
|
||
name = p.name,
|
||
col = p.col,
|
||
startA = cum,
|
||
endA = cum + arc,
|
||
midA = cum + arc / 2,
|
||
}
|
||
cum = cum + arc
|
||
end
|
||
return segs
|
||
end
|
||
|
||
-- Spin physics
|
||
local OMEGA_MIN = 8.0 -- rad/s fast launch
|
||
local OMEGA_MAX = 16.0
|
||
local FRICTION = 0.980 -- per frame — stops cleanly in ~4s
|
||
local STOP_OMEGA = 0.10 -- rad/s clean stop threshold, no wiggle
|
||
|
||
-- Colours
|
||
local COL_BG = 0x050505
|
||
local COL_RIM = 0x8B6914
|
||
local COL_HUB = 0x1A1A1A
|
||
local COL_HUB_RING = 0x8B6914
|
||
local COL_SEP = 0xE0E0E0
|
||
local COL_WHITE = 0xFFFFFF
|
||
local COL_POINTER = 0xF5F5F5
|
||
local COL_PTR_SHD = 0x444444
|
||
|
||
----------------------------------------------------------------------
|
||
-- GPU / pixel primitives
|
||
----------------------------------------------------------------------
|
||
|
||
local gpu
|
||
local PW, PH
|
||
local CX, CY, R_OUTER, R_POCKET_IN
|
||
|
||
local function px_rect(x, y, w, h, col)
|
||
x = math.floor(x); y = math.floor(y)
|
||
w = math.floor(w); h = math.floor(h)
|
||
if x < 1 then w = w + x - 1; x = 1 end
|
||
if y < 1 then h = h + y - 1; y = 1 end
|
||
if x + w - 1 > PW then w = PW - x + 1 end
|
||
if y + h - 1 > PH then h = PH - y + 1 end
|
||
if w < 1 or h < 1 then return end
|
||
gpu.filledRectangle(x, y, w, h, col)
|
||
end
|
||
|
||
local function px_circle(cx, cy, r, col)
|
||
cx = math.floor(cx); cy = math.floor(cy); r = math.floor(r)
|
||
for dy = -r, r do
|
||
local half = math.floor(math.sqrt(r*r - dy*dy) + 0.5)
|
||
px_rect(cx - half, cy + dy, half*2 + 1, 1, col)
|
||
end
|
||
end
|
||
|
||
local function px_annulus(cx, cy, r1, r2, col)
|
||
cx = math.floor(cx); cy = math.floor(cy)
|
||
r1 = math.floor(r1); r2 = math.floor(r2)
|
||
for dy = -r2, r2 do
|
||
local ho = math.floor(math.sqrt(math.max(0, r2*r2 - dy*dy)) + 0.5)
|
||
local hi = math.floor(math.sqrt(math.max(0, r1*r1 - dy*dy)) + 0.5)
|
||
if ho > hi then
|
||
px_rect(cx - ho, cy + dy, ho - hi, 1, col)
|
||
px_rect(cx + hi, cy + dy, ho - hi + 1, 1, col)
|
||
elseif hi == 0 then
|
||
px_rect(cx - ho, cy + dy, ho*2 + 1, 1, col)
|
||
end
|
||
end
|
||
end
|
||
|
||
local function px_text(str, x, y, fg, bg, size)
|
||
pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0)
|
||
end
|
||
|
||
local function px_text_centre(str, y, fg, bg, size)
|
||
size = size or 1
|
||
local w = #str * 6 * size
|
||
px_text(str, CX - math.floor(w / 2), y, fg, bg, size)
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Wheel drawing — single-pass rasteriser
|
||
--
|
||
-- Instead of drawing each wedge separately (N passes over the full
|
||
-- bounding box), we do ONE pass: for every pixel inside the wheel
|
||
-- annulus, compute its angle and look up the wedge colour.
|
||
-- This is N× faster and eliminates the per-wedge sleep(0) during spin.
|
||
----------------------------------------------------------------------
|
||
|
||
local segments = {}
|
||
|
||
-- Build a flat lookup: given a wheel-local angle in [0, TWO_PI),
|
||
-- return the segment index. Linear scan over 12 segments is fine.
|
||
local function segmentForAngle(a)
|
||
for i, seg in ipairs(segments) do
|
||
if a >= seg.startA and a < seg.endA then return i end
|
||
end
|
||
return #segments -- wrap-around safety
|
||
end
|
||
|
||
-- Draw the full wheel in one raster pass.
|
||
-- wheelAngle: current rotation offset (added to every wedge startA/endA).
|
||
-- glowIdx: if non-nil, that segment is brightened.
|
||
local function drawWheel(wheelAngle, glowIdx)
|
||
-- Precompute brightened colours so we don't recompute inside the loop
|
||
local cols = {}
|
||
for i, seg in ipairs(segments) do
|
||
if i == glowIdx then
|
||
local c = seg.col
|
||
local r = math.min(255, math.floor(c / 0x10000) + 80)
|
||
local g = math.min(255, math.floor((c % 0x10000) / 0x100) + 80)
|
||
local b = math.min(255, c % 0x100 + 80)
|
||
cols[i] = r * 0x10000 + g * 0x100 + b
|
||
else
|
||
cols[i] = seg.col
|
||
end
|
||
end
|
||
|
||
local ri = R_POCKET_IN
|
||
local ro = R_OUTER
|
||
local ri2 = ri * ri
|
||
local ro2 = ro * ro
|
||
|
||
-- Normalise wheelAngle so angles stay in a sane range
|
||
local wa = wheelAngle % TWO_PI
|
||
|
||
for sy = math.floor(CY - ro) - 1, math.ceil(CY + ro) + 1 do
|
||
local dy = sy - CY
|
||
local dy2 = dy * dy
|
||
if dy2 <= ro2 then
|
||
local xhalf = math.floor(math.sqrt(ro2 - dy2) + 0.5)
|
||
local runStart = nil
|
||
local runCol = nil
|
||
for sx = CX - xhalf, CX + xhalf do
|
||
local dx = sx - CX
|
||
local dx2 = dx * dx
|
||
local d2 = dx2 + dy2
|
||
local col = nil
|
||
if d2 >= ri2 and d2 <= ro2 then
|
||
-- Inside annulus — find wedge
|
||
-- atan2 returns angle in world space; subtract wheelAngle
|
||
-- to get wheel-local angle, then mod into [0, TWO_PI)
|
||
local worldA = math.atan2(dy, dx)
|
||
local localA = (worldA - wa) % TWO_PI
|
||
local idx = segmentForAngle(localA)
|
||
col = cols[idx]
|
||
end
|
||
if col == runCol then
|
||
-- extend run (including col==nil for outside-annulus gaps)
|
||
else
|
||
if runCol and runStart then
|
||
px_rect(runStart, sy, sx - runStart, 1, runCol)
|
||
end
|
||
runStart = sx
|
||
runCol = col
|
||
end
|
||
end
|
||
-- flush last run
|
||
if runCol and runStart then
|
||
local xend = CX + xhalf + 1
|
||
px_rect(runStart, sy, xend - runStart, 1, runCol)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Separator spokes — draw after fill so they appear on top
|
||
for i, seg in ipairs(segments) do
|
||
local a = seg.startA + wa
|
||
for step = 0, math.floor(R_OUTER - R_POCKET_IN) do
|
||
local rr = R_POCKET_IN + step
|
||
px_rect(math.floor(CX + math.cos(a) * rr),
|
||
math.floor(CY + math.sin(a) * rr), 2, 1, COL_SEP)
|
||
end
|
||
-- Label
|
||
local midA = seg.midA + wa
|
||
local lr = (R_POCKET_IN + R_OUTER) * 0.68
|
||
local lx = CX + math.cos(midA) * lr
|
||
local ly = CY + math.sin(midA) * lr
|
||
px_text(seg.name, lx - math.floor(#seg.name * 3), ly - 4,
|
||
COL_WHITE, cols[i], 1)
|
||
end
|
||
end
|
||
|
||
local function drawChrome()
|
||
px_annulus(CX, CY, R_OUTER, R_OUTER + 7, COL_RIM)
|
||
px_circle(CX, CY, R_POCKET_IN, COL_HUB)
|
||
px_annulus(CX, CY, R_POCKET_IN - 4, R_POCKET_IN, COL_HUB_RING)
|
||
px_circle(CX, CY, 6, COL_HUB_RING)
|
||
px_circle(CX, CY, 3, COL_HUB)
|
||
end
|
||
|
||
-- Fixed downward-pointing triangle at top of screen, above wheel
|
||
local function drawPointer()
|
||
local tipX = CX
|
||
local tipY = CY - R_OUTER - 8
|
||
local baseY = tipY - 20
|
||
local halfW = 11
|
||
for i = 0, 20 do
|
||
local frac = i / 20
|
||
local hw = math.floor(halfW * (1 - frac)) + 1
|
||
px_rect(tipX - hw + 2, baseY + i + 2, hw*2, 1, COL_PTR_SHD)
|
||
end
|
||
for i = 0, 20 do
|
||
local frac = i / 20
|
||
local hw = math.floor(halfW * (1 - frac)) + 1
|
||
px_rect(tipX - hw, baseY + i, hw*2, 1, COL_POINTER)
|
||
end
|
||
end
|
||
|
||
local function drawHubText(lines)
|
||
local r = R_POCKET_IN - 5
|
||
px_circle(CX, CY, r, COL_HUB)
|
||
local lineH = 12
|
||
local totalH = #lines * lineH
|
||
local startY = CY - math.floor(totalH / 2)
|
||
for i, line in ipairs(lines) do
|
||
local lx = CX - math.floor(#line * 6 / 2)
|
||
px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, 1)
|
||
end
|
||
px_annulus(CX, CY, R_POCKET_IN - 4, R_POCKET_IN, COL_HUB_RING)
|
||
gpu.sync()
|
||
end
|
||
|
||
local function drawWheelFull(wheelAngle, glowIdx)
|
||
px_circle(CX, CY, R_OUTER + 9, COL_BG)
|
||
drawWheel(wheelAngle, glowIdx)
|
||
drawChrome()
|
||
drawPointer()
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Segment under pointer
|
||
-- Pointer is at screen top = angle -pi/2.
|
||
-- Wheel-local angle = (-pi/2 - wheelAngle) mod TWO_PI
|
||
----------------------------------------------------------------------
|
||
|
||
local function segmentAtPointer(wheelAngle)
|
||
local a = (-math.pi / 2 - wheelAngle) % TWO_PI
|
||
for i, seg in ipairs(segments) do
|
||
if a >= seg.startA and a < seg.endA then return i end
|
||
end
|
||
return 1
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Spin physics
|
||
----------------------------------------------------------------------
|
||
|
||
local function spin()
|
||
local omega = OMEGA_MIN + math.random() * (OMEGA_MAX - OMEGA_MIN)
|
||
local angle = math.random() * TWO_PI
|
||
|
||
-- Initial draw
|
||
drawWheelFull(angle, nil)
|
||
drawHubText({ "SPINNING..." })
|
||
|
||
local frameCount = 0
|
||
while true do
|
||
omega = omega * FRICTION
|
||
angle = angle + omega * FRAME_DELAY
|
||
if angle > TWO_PI * 100 then angle = angle % TWO_PI end
|
||
|
||
-- drawWheel overwrites every annulus pixel — no need to clear first
|
||
drawWheel(angle, nil)
|
||
drawChrome()
|
||
drawPointer()
|
||
gpu.sync()
|
||
|
||
-- Yield to OS every 4 frames to avoid CC timeout without a sleep(0) overhead
|
||
frameCount = frameCount + 1
|
||
if frameCount % 4 == 0 then sleep(0) end
|
||
|
||
if omega < STOP_OMEGA then break end
|
||
end
|
||
|
||
local winIdx = segmentAtPointer(angle)
|
||
|
||
-- Flash winning wedge — 6 flashes at 120ms each
|
||
for flash = 1, 6 do
|
||
drawWheel(angle, flash % 2 == 1 and winIdx or nil)
|
||
drawChrome()
|
||
drawPointer()
|
||
gpu.sync()
|
||
sleep(0.12)
|
||
end
|
||
|
||
return winIdx, angle
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Redstone helper
|
||
----------------------------------------------------------------------
|
||
|
||
local function waitForRedstonePulse()
|
||
while true do
|
||
os.pullEvent("redstone")
|
||
for _, side in ipairs(redstone.getSides()) do
|
||
if redstone.getInput(side) then return end
|
||
end
|
||
end
|
||
end
|
||
|
||
----------------------------------------------------------------------
|
||
-- Lifecycle
|
||
----------------------------------------------------------------------
|
||
|
||
local function start()
|
||
math.randomseed(os.epoch("utc"))
|
||
|
||
gpu = findGPU()
|
||
if not gpu then error("No GPU peripheral found.") end
|
||
|
||
gpu.refreshSize()
|
||
sleep(0)
|
||
gpu.setSize(64)
|
||
|
||
PW, PH = gpu.getSize()
|
||
print(("[prizewheel] GPU: %dx%d px"):format(PW, PH))
|
||
if not PW or PW < 128 or PH < 128 then
|
||
error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0))
|
||
end
|
||
|
||
CX = math.floor(PW / 2)
|
||
CY = math.floor(PH / 2)
|
||
local R_MAX = math.floor(math.min(PW, PH) / 2) - 16
|
||
R_OUTER = R_MAX
|
||
R_POCKET_IN = math.floor(R_MAX * 0.28)
|
||
|
||
segments = buildSegments()
|
||
|
||
-- Clear full screen before drawing anything
|
||
gpu.fill(COL_BG)
|
||
gpu.sync()
|
||
|
||
drawWheelFull(0, nil)
|
||
drawHubText({ "PRIZE WHEEL", "Pull lever" })
|
||
end
|
||
|
||
local function stop()
|
||
if gpu then gpu.fill(COL_BG); gpu.sync() end
|
||
end
|
||
|
||
local function main()
|
||
while true do
|
||
waitForRedstonePulse()
|
||
|
||
local winIdx, finalAngle = spin()
|
||
local prize = segments[winIdx]
|
||
|
||
drawHubText({ "WINNER!", prize.name })
|
||
|
||
sleep(5)
|
||
|
||
gpu.fill(COL_BG)
|
||
gpu.sync()
|
||
drawWheelFull(finalAngle, nil)
|
||
drawHubText({ "PRIZE WHEEL", "Pull lever" })
|
||
end
|
||
end
|
||
|
||
return { start = start, stop = stop, main = main }
|