Files
nova-corp/programs/plinko.lua
2026-05-05 19:42:36 -04:00

543 lines
18 KiB
Lua

-- Plichinko Machine (fully physics-based)
-- Tom's Peripherals GPU + screen wall (any size).
--
-- A ball is dropped from a random X position near the top.
-- It falls under gravity and collides with circular pegs using
-- proper elastic circle-circle collision response (reflect velocity
-- along the contact normal). The ball also bounces off the side
-- walls and the bucket dividers. The bucket it lands in is
-- determined entirely by the physics — no predetermined outcome.
--
-- Trigger: redstone pulse on any side.
----------------------------------------------------------------------
-- GPU discovery
----------------------------------------------------------------------
local function findGPU()
print("[plinko] 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("[plinko] Using GPU: " .. name)
return peripheral.wrap(name)
end
end
return nil
end
----------------------------------------------------------------------
-- Physics / display constants
----------------------------------------------------------------------
local FRAME_DELAY = 0.02 -- s (~50 fps)
local DT = FRAME_DELAY
local GRAVITY = 700 -- px/s²
local RESTITUTION = 0.55 -- fraction of normal speed kept after peg bounce
local FRICTION = 0.88 -- fraction of tangential speed kept after peg bounce
local WALL_RESTITUTION = 0.45 -- side wall bounce
local FLOOR_RESTITUTION = 0.38 -- bucket-floor bounce (ball settles after a few hops)
local DIVIDER_RESTITUTION = 0.30 -- bucket divider bounce
local MAX_SPEED = 1200 -- px/s (clamp to avoid tunnelling)
local SETTLE_VY = 18 -- px/s below this vertical speed the ball is "at rest"
local PEG_RADIUS = 7 -- px
local BALL_RADIUS = 9 -- px
local COMBINED_R = PEG_RADIUS + BALL_RADIUS
local PEG_ROWS = 9
local PEG_COLS_TOP = 3 -- pegs in first (narrowest) row
-- bottom row has PEG_COLS_TOP + PEG_ROWS - 1 pegs
local BUCKET_H = 60 -- px
local BOARD_MARGIN = 48 -- px left/right margin
-- Colours
local COL_BG = 0x080C14
local COL_BOARD = 0x0E1620
local COL_PEG = 0xCFD8DC
local COL_PEG_HIT = 0xFFFFFF -- flash colour when struck
local COL_BALL = 0xFFD600
local COL_BALL_SHADE = 0xBB8800
local COL_WALL = 0x1C2840
local COL_TEXT = 0xFFFFFF
-- Buckets: defined centre-outward; mirrored.
local BUCKET_DEFS = {
-- { label, colour, multiplier }
{ "100x", 0xF9A825, 100 },
{ "10x", 0x1565C0, 10 },
{ "5x", 0x2E7D32, 5 },
{ "2x", 0x4CAF50, 2 },
{ "1x", 0x37474F, 1 },
}
----------------------------------------------------------------------
-- Module-level GPU state
----------------------------------------------------------------------
local gpu
local PW, PH
----------------------------------------------------------------------
-- Drawing helpers
----------------------------------------------------------------------
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_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 2
local w = #str * 7 * size
px_text(str, math.max(1, math.floor((PW - w) / 2)), y, fg, bg, size)
end
----------------------------------------------------------------------
-- Board geometry
----------------------------------------------------------------------
local pegs = {}
local buckets = {}
local NUM_BUCKETS
local colSpacing, rowSpacing
local boardLeft, boardRight, boardTop, boardBottom
local bucketTop -- y of the top of bucket row
local function buildBoard()
pegs = {}
buckets = {}
local lastRowPegs = PEG_COLS_TOP + PEG_ROWS - 1
NUM_BUCKETS = lastRowPegs + 1
boardLeft = BOARD_MARGIN
boardRight = PW - BOARD_MARGIN
colSpacing = math.floor((boardRight - boardLeft) / lastRowPegs)
-- Reserve top 55px for title, bottom BUCKET_H + 10 for buckets.
rowSpacing = math.floor((PH - BUCKET_H - 75) / (PEG_ROWS + 1))
boardTop = 65
boardBottom = boardTop + (PEG_ROWS - 1) * rowSpacing
for row = 1, PEG_ROWS do
local pegsInRow = PEG_COLS_TOP + row - 1
local rowW = (pegsInRow - 1) * colSpacing
local startX = math.floor(PW / 2 - rowW / 2)
local py = boardTop + (row - 1) * rowSpacing
for col = 1, pegsInRow do
-- Jitter each peg slightly so the layout is never identical
local jx = math.floor((math.random() - 0.5) * colSpacing * 0.35)
local jy = math.floor((math.random() - 0.5) * rowSpacing * 0.35)
table.insert(pegs, {
x = startX + (col - 1) * colSpacing + jx,
y = py + jy,
})
end
end
-- Bucket top is one rowSpacing below the last peg row.
bucketTop = boardBottom + rowSpacing - math.floor(rowSpacing * 0.2)
local bucketW = colSpacing
local totalBW = NUM_BUCKETS * bucketW
local startBX = math.floor(PW / 2 - totalBW / 2)
local halfB = math.floor(NUM_BUCKETS / 2)
for i = 1, NUM_BUCKETS do
local dist
if NUM_BUCKETS % 2 == 1 then
dist = math.abs(i - (halfB + 1))
else
dist = math.max(0, math.abs(i - halfB) - 1)
end
local def = BUCKET_DEFS[math.min(dist + 1, #BUCKET_DEFS)]
table.insert(buckets, {
x = startBX + (i - 1) * bucketW,
y = bucketTop,
w = bucketW,
label = def[1],
color = def[2],
mult = def[3],
})
end
end
----------------------------------------------------------------------
-- Identify which bucket an X coordinate falls in (physics outcome)
----------------------------------------------------------------------
local function bucketForX(x)
for i, b in ipairs(buckets) do
if x >= b.x and x < b.x + b.w then return i end
end
-- Clamp to edges
if x < buckets[1].x then return 1 end
return NUM_BUCKETS
end
----------------------------------------------------------------------
-- Static board draw
----------------------------------------------------------------------
local function drawBucket(i, glowing)
local b = buckets[i]
local bg = glowing and 0xFFFFFF or b.color
local fg = glowing and b.color or COL_TEXT
px_rect(b.x, b.y, b.w - 1, BUCKET_H, bg)
-- Divider
px_rect(b.x + b.w - 1, b.y, 1, BUCKET_H, COL_BOARD)
-- Centred label
local lw = #b.label * 7
local lx = b.x + math.floor((b.w - lw) / 2)
local ly = b.y + math.floor((BUCKET_H - 9) / 2)
px_text(b.label, lx, ly, fg, bg, 1)
end
local function drawAllBuckets(glowIdx)
for i = 1, NUM_BUCKETS do
drawBucket(i, i == glowIdx)
end
end
local function drawPeg(p, col)
px_circle(p.x, p.y, PEG_RADIUS, col or COL_PEG)
end
local function drawBoard()
px_rect(1, 1, PW, PH, COL_BG)
-- Board area background
px_rect(boardLeft - 8, boardTop - 18,
boardRight - boardLeft + 16, bucketTop - boardTop + 18 + BUCKET_H + 4,
COL_BOARD)
-- Left/right walls
px_rect(boardLeft - 8, boardTop - 18, 8, bucketTop - boardTop + 18, COL_WALL)
px_rect(boardRight, boardTop - 18, 8, bucketTop - boardTop + 18, COL_WALL)
for _, p in ipairs(pegs) do drawPeg(p) end
drawAllBuckets()
px_text_centre("Plichinko", 8, 0xFFD600, COL_BG, 3)
px_text_centre("Pull lever to drop", 46, 0x607080, COL_BG, 1)
end
----------------------------------------------------------------------
-- Physics simulation — returns final bucket index
----------------------------------------------------------------------
-- Erase the ball at (lx,ly) and restore any pegs it was covering.
local function eraseBall(lx, ly)
local r = BALL_RADIUS + 3
px_circle(lx, ly, r, COL_BOARD)
-- Restore wall stripes if needed
if lx - r < boardLeft then
px_rect(boardLeft - 8, ly - r, 8, r * 2 + 1, COL_WALL)
end
if lx + r > boardRight then
px_rect(boardRight, ly - r, 8, r * 2 + 1, COL_WALL)
end
-- Restore any pegs within the erased disc
for _, p in ipairs(pegs) do
local dx = lx - p.x
local dy = ly - p.y
if dx*dx + dy*dy <= (r + PEG_RADIUS + 1)^2 then
drawPeg(p)
end
end
end
local function drawBall(bx, by)
local ix, iy = math.floor(bx), math.floor(by)
px_circle(ix + 2, iy + 2, BALL_RADIUS, 0x1A1200) -- shadow
px_circle(ix, iy, BALL_RADIUS, COL_BALL)
-- simple highlight
px_circle(ix - 3, iy - 3, math.max(2, math.floor(BALL_RADIUS * 0.35)), 0xFFF8C0)
end
local function physicsLoop()
-- Re-randomise peg positions each drop
buildBoard()
drawBoard()
gpu.sync()
-- Random drop X anywhere across the board width
local dropX = boardLeft + math.random() * (boardRight - boardLeft)
local dropY = boardTop - rowSpacing * 0.6
-- Random launch angle: mostly downward but tilted ±35°
local launchAngle = (math.pi / 2) + (math.random() - 0.5) * (math.pi / 180 * 70)
local launchSpeed = 180 + math.random() * 220 -- px/s
local bx = dropX
local by = dropY
local vx = math.cos(launchAngle) * launchSpeed
local vy = math.sin(launchAngle) * launchSpeed -- positive = downward (screen coords)
local lastBx, lastBy = bx, by
local elapsed = 0
local MAX_TIME = 14.0
-- Per-peg cooldown to prevent vibrating stuck against one peg.
local pegCooldown = {}
-- Pre-build bucket divider X positions (walls between buckets).
-- Each divider is a thin vertical wall from bucketTop downward.
local dividers = {}
for i = 1, NUM_BUCKETS - 1 do
table.insert(dividers, buckets[i].x + buckets[i].w)
end
local bucketFloor = bucketTop + BUCKET_H
local inBucket = false -- true once ball has entered bucket zone
-- Draw initial ball
drawBall(bx, by)
gpu.sync()
while elapsed < MAX_TIME do
vy = vy + GRAVITY * DT
bx = bx + vx * DT
by = by + vy * DT
-- Speed clamp
local spd = math.sqrt(vx*vx + vy*vy)
if spd > MAX_SPEED then
local s = MAX_SPEED / spd
vx = vx * s; vy = vy * s
end
-- Tick peg cooldowns
for k, v in pairs(pegCooldown) do
pegCooldown[k] = v - DT
if pegCooldown[k] <= 0 then pegCooldown[k] = nil end
end
-- ── Peg collisions (only while above bucket zone) ───────────
if not inBucket then
for i, p in ipairs(pegs) do
if not pegCooldown[i] then
local dx = bx - p.x
local dy = by - p.y
local distSq = dx*dx + dy*dy
if distSq < COMBINED_R * COMBINED_R and distSq > 0 then
local dist = math.sqrt(distSq)
local nx = dx / dist
local ny = dy / dist
-- Depenetrate
local overlap = COMBINED_R - dist
bx = bx + nx * overlap
by = by + ny * overlap
-- Velocity reflection
local vn = vx * nx + vy * ny
local vnx = vn * nx; local vny = vn * ny
local vtx = vx - vnx; local vty = vy - vny
vx = -vnx * RESTITUTION + vtx * FRICTION
vy = -vny * RESTITUTION + vty * FRICTION
-- Guarantee separation
local newVn = vx * nx + vy * ny
if newVn < 0 then
vx = vx - newVn * nx
vy = vy - newVn * ny
end
pegCooldown[i] = 0.08
drawPeg(p, COL_PEG_HIT)
end
end
end
end
-- ── Side wall collisions ─────────────────────────────────────
if bx - BALL_RADIUS < boardLeft then
bx = boardLeft + BALL_RADIUS
vx = math.abs(vx) * WALL_RESTITUTION
end
if bx + BALL_RADIUS > boardRight then
bx = boardRight - BALL_RADIUS
vx = -math.abs(vx) * WALL_RESTITUTION
end
-- ── Entered bucket zone? ─────────────────────────────────────
if by + BALL_RADIUS >= bucketTop then
inBucket = true
end
-- ── Bucket physics (dividers + floor) ────────────────────────
if inBucket then
-- Bucket divider walls (thin vertical pillars)
for _, dx in ipairs(dividers) do
if bx + BALL_RADIUS > dx and bx - BALL_RADIUS < dx then
if vx > 0 then
bx = dx - BALL_RADIUS
else
bx = dx + BALL_RADIUS
end
vx = -vx * DIVIDER_RESTITUTION
end
end
-- Bucket floor bounce
if by + BALL_RADIUS >= bucketFloor then
by = bucketFloor - BALL_RADIUS
vy = -math.abs(vy) * FLOOR_RESTITUTION
vx = vx * 0.80 -- extra horizontal damping on floor hit
-- Settle: if vertical bounce is tiny, stop
if math.abs(vy) < SETTLE_VY then
vy = 0
vx = vx * 0.5
if math.abs(vx) < 4 then
vx = 0
break
end
end
end
end
-- Render
eraseBall(math.floor(lastBx), math.floor(lastBy))
drawBall(bx, by)
gpu.sync()
sleep(DT)
lastBx, lastBy = bx, by
elapsed = elapsed + DT
end
-- Final render
eraseBall(math.floor(lastBx), math.floor(lastBy))
drawBall(bx, by)
gpu.sync()
sleep(0.3)
-- Erase ball
eraseBall(math.floor(bx), math.floor(by))
gpu.sync()
return bucketForX(bx)
end
----------------------------------------------------------------------
-- Win flash
----------------------------------------------------------------------
local function flashBucket(idx)
for i = 1, 8 do
drawBucket(idx, i % 2 == 1)
gpu.sync()
sleep(0.12)
end
drawBucket(idx, false)
gpu.sync()
end
----------------------------------------------------------------------
-- Result banner
----------------------------------------------------------------------
local function showResult(mult)
local msg = mult .. "x MULTIPLIER!"
local bh = 40
local by_ = math.floor(PH / 2 - bh / 2)
local marg = 40
px_rect(marg, by_, PW - marg * 2, bh, 0x000000)
px_text_centre(msg, by_ + math.floor((bh - 18) / 2), 0xFFD600, 0x000000, 2)
gpu.sync()
sleep(3)
-- Clear banner
px_rect(marg, by_, PW - marg * 2, bh, COL_BOARD)
-- Restore any pegs behind the banner
for _, p in ipairs(pegs) do
if p.y >= by_ - PEG_RADIUS and p.y <= by_ + bh + PEG_RADIUS then
drawPeg(p)
end
end
gpu.sync()
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("[plichinko] No GPU peripheral found.") end
gpu.refreshSize()
sleep(0)
gpu.setSize(64)
PW, PH = gpu.getSize()
print(("[plichinko] GPU %dx%d px"):format(PW, PH))
if not PW or PW < 128 or PH < 128 then
error(("GPU size %dx%d too small."):format(PW or 0, PH or 0))
end
buildBoard()
print(("[plichinko] %d pegs, %d buckets"):format(#pegs, NUM_BUCKETS))
drawBoard()
gpu.sync()
end
local function stop()
if gpu then gpu.fill(COL_BG); gpu.sync() end
end
local function main()
while true do
waitForRedstonePulse()
-- Run physics — redraws board with fresh peg positions, then drops
local winIdx = physicsLoop()
-- Celebrate
flashBucket(winIdx)
showResult(buckets[winIdx].mult)
-- Redraw clean board (fresh pegs already in place from physicsLoop)
drawBoard()
gpu.sync()
end
end
return { start = start, stop = stop, main = main }