This commit is contained in:
2026-05-05 19:59:28 -04:00
parent e1e07aa357
commit 4f34b6f6fd
2 changed files with 679 additions and 141 deletions

473
programs/prizewheel.lua Normal file
View File

@@ -0,0 +1,473 @@
-- Prize Wheel — spinning prize wheel, perspective view
-- Tom's Peripherals GPU + screen wall.
--
-- The wheel is drawn as a perspective ellipse (top tilted away from viewer).
-- It physically rotates: angular velocity starts high and decays under
-- friction until it stops. A fixed pointer at the top selects the prize.
--
-- Rendering approach:
-- A point at wheel angle `a`, radius fraction `f` maps to screen as:
-- sx = CX + f * RX * cos(a + wheelAngle)
-- sy = WY + f * RY * sin(a + wheelAngle) (RY = RX * TILT)
-- Wedges are rasterised row-by-row using ellipse span math.
----------------------------------------------------------------------
-- 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 -- ~30 fps
local TWO_PI = math.pi * 2
-- Perspective tilt: RY / RX. 0 = edge-on, 1 = top-down.
-- 0.28 ≈ wheel tilted ~74° toward viewer (like a real prize wheel on a stand).
local TILT = 0.28
-- Prize segments — name, colour, relative weight (wider = more likely)
local PRIZES = {
{ name = "$100", col = 0xF44336, weight = 2 }, -- red
{ name = "SPIN AGAIN", col = 0xFFEB3B, weight = 3 }, -- yellow
{ name = "$500", col = 0x4CAF50, weight = 1 }, -- green
{ name = "BANKRUPT", col = 0x212121, weight = 2 }, -- near-black
{ name = "$250", col = 0x2196F3, weight = 2 }, -- blue
{ name = "$50", col = 0xFF9800, weight = 3 }, -- orange
{ name = "JACKPOT", col = 0xE91E63, weight = 1 }, -- pink
{ name = "$150", col = 0x9C27B0, weight = 2 }, -- purple
{ name = "LOSE TURN", col = 0x607D8B, weight = 2 }, -- grey-blue
{ name = "$75", col = 0x00BCD4, weight = 3 }, -- cyan
{ name = "$1000", col = 0x8BC34A, weight = 1 }, -- lime
{ name = "$25", col = 0xFF5722, weight = 3 }, -- deep orange
}
-- Build cumulative angle table from weights
local function buildSegments()
local total = 0
for _, p in ipairs(PRIZES) do total = total + p.weight end
local segs = {}
local cumAngle = 0
for i, p in ipairs(PRIZES) do
local arc = (p.weight / total) * TWO_PI
segs[i] = {
name = p.name,
col = p.col,
startA = cumAngle,
endA = cumAngle + arc,
midA = cumAngle + arc / 2,
}
cumAngle = cumAngle + arc
end
return segs
end
-- Physics spin constants
local OMEGA_MIN = 4.0 -- rad/s minimum starting spin
local OMEGA_MAX = 10.0 -- rad/s maximum starting spin
local FRICTION = 0.987 -- angular velocity multiplier per frame
local STOP_OMEGA = 0.04 -- rad/s below this we consider it stopped
-- Colours
local COL_BG = 0x050505
local COL_RIM = 0x8B6914 -- gold rim
local COL_RIM_DARK = 0x5C4400
local COL_SPOKE = 0xB8860B
local COL_HUB = 0x333333
local COL_HUB_RING = 0x8B6914
local COL_SEP = 0xFFFFFF -- wedge separator lines
local COL_STAND = 0x5D4037 -- wood brown
local COL_STAND_DRK = 0x3E2723
local COL_POINTER = 0xF5F5F5
local COL_POINTER_S = 0x444444
local COL_WHITE = 0xFFFFFF
local COL_GLOW = 0xFFD600 -- winner highlight
----------------------------------------------------------------------
-- GPU / pixel primitives
----------------------------------------------------------------------
local gpu
local PW, PH
-- Screen geometry (set in start())
local CX, WY -- wheel centre x, wheel centre y
local RX, RY -- wheel half-width, half-height (perspective)
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_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
-- Filled ellipse
local function px_ellipse(cx, cy, rx, ry, col)
cx = math.floor(cx); cy = math.floor(cy)
rx = math.floor(rx); ry = math.floor(ry)
if rx < 1 or ry < 1 then return end
for dy = -ry, ry do
local t = dy / ry
local half = math.floor(rx * math.sqrt(math.max(0, 1 - t*t)) + 0.5)
if half >= 1 then
px_rect(cx - half, cy + dy, half * 2 + 1, 1, col)
end
end
end
-- Ellipse annulus
local function px_ellipse_annulus(cx, cy, rx1, ry1, rx2, ry2, col)
cx = math.floor(cx); cy = math.floor(cy)
for dy = -ry2, ry2 do
local t2 = dy / ry2
local ho = math.floor(rx2 * math.sqrt(math.max(0, 1 - t2*t2)) + 0.5)
local hi = 0
if math.abs(dy) <= ry1 then
local t1 = dy / ry1
hi = math.floor(rx1 * math.sqrt(math.max(0, 1 - t1*t1)) + 0.5)
end
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
----------------------------------------------------------------------
-- Wheel drawing
----------------------------------------------------------------------
local segments = {}
-- Convert wheel-local polar (angle, radius fraction) → screen (sx, sy)
-- wheelAngle is the current rotation offset
local function wheelToScreen(a, f, wheelAngle)
local wa = a + wheelAngle
return CX + f * RX * math.cos(wa),
WY + f * RY * math.sin(wa)
end
-- Draw a single wedge of the perspective ellipse.
-- seg.startA / seg.endA are in wheel-local angles.
-- wheelAngle rotates the whole wheel.
local function drawWedge(seg, wheelAngle, glowing)
local col = seg.col
if glowing then
local r = math.min(255, math.floor(col / 0x10000) + 80)
local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 80)
local b = math.min(255, col % 0x100 + 80)
col = r * 0x10000 + g * 0x100 + b
end
-- Rasterise the wedge by scanning screen rows within the ellipse bounding box.
local by0 = math.floor(WY - RY) - 1
local by1 = math.ceil (WY + RY) + 1
local bx0 = math.floor(CX - RX) - 1
local bx1 = math.ceil (CX + RX) + 1
local a0 = seg.startA + wheelAngle
local a1 = seg.endA + wheelAngle
local arc = seg.endA - seg.startA -- always positive
for sy = by0, by1 do
local dy = sy - WY
-- ellipse x half-span at this row
if math.abs(dy) <= RY then
local t = dy / RY
local xhalf = RX * math.sqrt(math.max(0, 1 - t*t))
local runStart = nil
for sx = math.floor(CX - xhalf), math.ceil(CX + xhalf) do
local dx = sx - CX
local angle = math.atan2(dy / RY, dx / RX) -- ellipse-normalised angle
local rel = (angle - a0) % TWO_PI
if rel <= arc then
if not runStart then runStart = sx end
else
if runStart then
px_rect(runStart, sy, sx - runStart, 1, col)
runStart = nil
end
end
end
if runStart then
px_rect(runStart, sy, math.ceil(CX + xhalf) - runStart + 1, 1, col)
end
end
end
-- Separator line at startA edge
local steps = math.floor(RX)
for i = 0, steps do
local f = i / steps
local sx = CX + f * RX * math.cos(a0)
local sy = WY + f * RY * math.sin(a0)
px_rect(math.floor(sx), math.floor(sy), 2, 1, COL_SEP)
end
-- Label at wedge midpoint, ~70% radius
local midA = seg.midA + wheelAngle
local lx = CX + 0.70 * RX * math.cos(midA)
local ly = WY + 0.70 * RY * math.sin(midA)
local label = seg.name
local lsize = (RX > 80) and 1 or 1
px_text(label, lx - math.floor(#label * 3), ly - 4, COL_WHITE, col, lsize)
end
local function drawAllWedges(wheelAngle, glowIdx)
for i, seg in ipairs(segments) do
drawWedge(seg, wheelAngle, i == glowIdx)
sleep(0)
end
end
local function drawChrome(wheelAngle)
-- Outer rim (ellipse annulus, slightly larger than wheel)
local rimRX = RX + 6; local rimRY = RY + math.floor(6 * TILT)
px_ellipse_annulus(CX, WY, RX, RY, rimRX, rimRY, COL_RIM)
-- Inner hub
local hubRX = math.floor(RX * 0.10)
local hubRY = math.floor(RY * 0.10)
px_ellipse(CX, WY, hubRX + 3, hubRY + 3, COL_HUB_RING)
px_ellipse(CX, WY, hubRX, hubRY, COL_HUB)
end
local function drawStand()
-- Two angled legs below the wheel
local baseY = WY + RY + 6
local legBot = PH - 4
local legW = 8
-- Left leg
local lx1 = CX - math.floor(RX * 0.3)
local lx2 = CX - math.floor(RX * 0.7)
-- Draw as a trapezoid approximation with filled rects
local steps = legBot - baseY
for i = 0, steps do
local frac = i / math.max(1, steps)
local cx_l = lx1 + math.floor((lx2 - lx1) * frac)
px_rect(cx_l - legW//2, baseY + i, legW, 1, COL_STAND)
end
-- Right leg
local rx1 = CX + math.floor(RX * 0.3)
local rx2 = CX + math.floor(RX * 0.7)
for i = 0, steps do
local frac = i / math.max(1, steps)
local cx_r = rx1 + math.floor((rx2 - rx1) * frac)
px_rect(cx_r - legW//2, baseY + i, legW, 1, COL_STAND)
end
-- Horizontal crossbar
local barY = baseY + math.floor(steps * 0.55)
px_rect(lx2 - legW//2, barY, rx2 - lx2 + legW, 6, COL_STAND_DRK)
end
-- Fixed pointer triangle at the top of the wheel (screen top, pointing down)
local function drawPointer()
local tipX = CX
local tipY = WY - RY - 5 -- just above the rim
local baseY = tipY - 18
local halfW = 10
-- Shadow
for i = 0, 18 do
local frac = i / 18
local hw = math.floor(halfW * (1 - frac)) + 1
px_rect(tipX - hw + 2, baseY + i + 2, hw*2, 1, COL_POINTER_S)
end
-- Arrow
for i = 0, 18 do
local frac = i / 18
local hw = math.floor(halfW * (1 - frac)) + 1
px_rect(tipX - hw, baseY + i, hw*2, 1, COL_POINTER)
end
end
local function drawWheelFull(wheelAngle, glowIdx)
-- Clear wheel area
local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT)
px_ellipse(CX, WY, rimRX, rimRY, COL_BG)
drawAllWedges(wheelAngle, glowIdx)
drawChrome(wheelAngle)
drawPointer()
end
----------------------------------------------------------------------
-- Center / result text (drawn below wheel, above stand)
----------------------------------------------------------------------
local function drawResultText(lines)
local y0 = WY + RY + math.floor(RY * 0.15)
-- Clear area
px_rect(1, y0, PW, 30, COL_BG)
for i, line in ipairs(lines) do
px_text_centre(line, y0 + (i-1) * 14, COL_WHITE, COL_BG, 1)
end
gpu.sync()
end
----------------------------------------------------------------------
-- Spin physics
----------------------------------------------------------------------
-- Find which segment index is currently under the pointer.
-- The pointer is at the top of the wheel = screen angle -pi/2.
-- In wheel-local space that is angle (-pi/2 - wheelAngle) mod TWO_PI.
local function segmentAtPointer(wheelAngle)
local pointerLocalAngle = (-math.pi / 2 - wheelAngle) % TWO_PI
for i, seg in ipairs(segments) do
if pointerLocalAngle >= seg.startA and pointerLocalAngle < seg.endA then
return i
end
end
return 1
end
local function spin()
local omega = OMEGA_MIN + math.random() * (OMEGA_MAX - OMEGA_MIN)
local angle = math.random() * TWO_PI -- random start rotation
local elapsed = 0
local MAX_TIME = 30.0
-- Draw wheel at initial angle
drawWheelFull(angle, nil)
gpu.sync()
while elapsed < MAX_TIME do
-- Decay angular velocity
omega = omega * FRICTION
-- Integrate angle
angle = angle + omega * FRAME_DELAY
-- Keep angle in [0, TWO_PI) to avoid float drift over long spins
if angle > TWO_PI * 100 then angle = angle % TWO_PI end
-- Erase wheel, redraw at new angle
local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT)
px_ellipse(CX, WY, rimRX, rimRY, COL_BG)
drawAllWedges(angle, nil)
drawChrome(angle)
drawPointer()
gpu.sync()
sleep(FRAME_DELAY)
elapsed = elapsed + FRAME_DELAY
if omega < STOP_OMEGA then break end
end
-- Final angle
local winIdx = segmentAtPointer(angle)
-- Glow animation
for flash = 1, 7 do
local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT)
px_ellipse(CX, WY, rimRX, rimRY, COL_BG)
drawAllWedges(angle, flash % 2 == 1 and winIdx or nil)
drawChrome(angle)
drawPointer()
gpu.sync()
sleep(0.14)
end
return winIdx
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
-- Geometry: wheel sits in the upper ~60% of the screen
CX = math.floor(PW / 2)
RX = math.floor(math.min(PW, PH) / 2) - 12
RY = math.floor(RX * TILT)
WY = math.floor(PH * 0.38) -- wheel centre sits above mid
segments = buildSegments()
gpu.fill(COL_BG)
drawStand()
drawWheelFull(0, nil)
drawResultText({ "Pull lever to spin!" })
end
local function stop()
if gpu then gpu.fill(COL_BG); gpu.sync() end
end
local function main()
while true do
waitForRedstonePulse()
drawResultText({ "SPINNING..." })
local winIdx = spin()
local prize = segments[winIdx]
drawResultText({ "WINNER!", prize.name })
sleep(5)
-- Redraw clean with a fresh random idle angle
gpu.fill(COL_BG)
drawStand()
drawWheelFull(math.random() * TWO_PI, nil)
drawResultText({ "Pull lever to spin!" })
end
end
return { start = start, stop = stop, main = main }