From 20c5ebac1d49430d253021c1fb8b2d486b927f80 Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 19:26:52 -0400 Subject: [PATCH] added plinko --- programs/plinko.lua | 504 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 programs/plinko.lua diff --git a/programs/plinko.lua b/programs/plinko.lua new file mode 100644 index 0000000..c154b78 --- /dev/null +++ b/programs/plinko.lua @@ -0,0 +1,504 @@ +-- Plinko 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 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 bounce +local FRICTION = 0.88 -- fraction of tangential speed kept after bounce +local WALL_RESTITUTION = 0.35 +local MAX_SPEED = 1200 -- px/s (clamp to avoid tunnelling) + +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 + table.insert(pegs, { + x = startX + (col - 1) * colSpacing, + y = py, + }) + 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("PLINKO", 8, 0xFFD600, COL_BG, 3) + px_text_centre("Pull lever to drop", 38, 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() + -- Random drop X between the leftmost and rightmost peg in the first row + -- (first row has PEG_COLS_TOP pegs; their positions were added first). + local firstRowPegs = PEG_COLS_TOP + local firstPeg = pegs[1] + local lastFirst = pegs[firstRowPegs] + local dropX = firstPeg.x + math.random() * (lastFirst.x - firstPeg.x) + local dropY = boardTop - rowSpacing * 0.6 + + local bx, by = dropX, dropY + local vx, vy = (math.random() - 0.5) * 40, 60 -- tiny initial nudge + + local lastBx, lastBy = bx, by + local elapsed = 0 + local MAX_TIME = 12.0 + + -- Per-peg cooldown: after hitting a peg, ignore it for a short time + -- to prevent the ball getting stuck vibrating against one peg. + local pegCooldown = {} -- peg index -> time remaining + + -- Draw initial ball + drawBall(bx, by) + gpu.sync() + + while elapsed < MAX_TIME do + -- Step + vy = vy + GRAVITY * DT + bx = bx + vx * DT + by = by + vy * DT + + -- Clamp speed + 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 (circle vs circle) + 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) + -- Contact normal (from peg centre to ball centre) + local nx = dx / dist + local ny = dy / dist + + -- Push ball out of overlap + local overlap = COMBINED_R - dist + bx = bx + nx * overlap + by = by + ny * overlap + + -- Decompose velocity into normal and tangential components + local vn = vx * nx + vy * ny -- normal component (scalar) + local vnx = vn * nx + local vny = vn * ny + local vtx = vx - vnx + local vty = vy - vny + + -- Reflect normal component, damp tangential (friction) + vx = -vnx * RESTITUTION + vtx * FRICTION + vy = -vny * RESTITUTION + vty * FRICTION + + -- Ensure ball moves away from peg + local newVn = vx * nx + vy * ny + if newVn < 0 then + vx = vx - newVn * nx + vy = vy - newVn * ny + end + + pegCooldown[i] = 0.08 -- ignore this peg for 80 ms + + -- Flash peg + drawPeg(p, COL_PEG_HIT) + -- (will be restored on next erase) + end + end + end + + -- Left/right 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 + + -- Reached bucket level? + if by + BALL_RADIUS >= bucketTop then + by = bucketTop - BALL_RADIUS + break + 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 at rest + 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("[plinko] No GPU peripheral found.") end + + gpu.refreshSize() + sleep(0) + gpu.setSize(64) + + PW, PH = gpu.getSize() + print(("[plinko] 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(("[plinko] %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() + + -- Erase subtitle, show "dropping" + px_text_centre("Pull lever to drop", 38, COL_BG, COL_BG, 1) + px_text_centre(" DROPPING... ", 38, 0xFFD600, COL_BG, 1) + gpu.sync() + sleep(0.25) + px_text_centre(" DROPPING... ", 38, COL_BG, COL_BG, 1) + px_text_centre("Pull lever to drop", 38, 0x607080, COL_BG, 1) + gpu.sync() + + -- Run physics — outcome determined by simulation + local winIdx = physicsLoop() + + -- Celebrate + flashBucket(winIdx) + showResult(buckets[winIdx].mult) + + -- Redraw clean board + drawBoard() + gpu.sync() + end +end + +return { start = start, stop = stop, main = main }