556 lines
18 KiB
Lua
556 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.
|
|
-- Also used to track glow: peg stays lit while cooldown > 0.
|
|
local pegCooldown = {} -- index -> time remaining
|
|
local pegLit = {} -- index -> true while glowing
|
|
|
|
-- 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; restore normal colour when glow expires
|
|
for k, v in pairs(pegCooldown) do
|
|
pegCooldown[k] = v - DT
|
|
if pegCooldown[k] <= 0 then
|
|
pegCooldown[k] = nil
|
|
if pegLit[k] then
|
|
drawPeg(pegs[k], COL_PEG)
|
|
pegLit[k] = nil
|
|
end
|
|
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.18 -- glow duration (s)
|
|
pegLit[i] = true
|
|
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))
|
|
|
|
-- Clear full screen before drawing anything
|
|
gpu.fill(COL_BG)
|
|
gpu.sync()
|
|
|
|
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 }
|