added plinko
This commit is contained in:
504
programs/plinko.lua
Normal file
504
programs/plinko.lua
Normal file
@@ -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 }
|
||||
Reference in New Issue
Block a user