--[[ MINECRAFT for OpenComputers (braille voxel raycaster) Ported from the ComputerCraft version. - Braille rendering: every character cell = 2x4 dots, 2 colours per cell - Time-sliced rendering (never hits "too long without yielding") - VRAM double buffering (gpu.allocateBuffer + gpu.bitblt): frames are built off-screen and only the changed rows are blitted, so no tearing between slices - Diff-based cell updates + run-length batching of gpu.set calls - Sparse world (only edits are stored) + heightmap shading - Exact DDA block picking, sliding collision, held-key movement - Textures: downloaded BMPs, or procedural fallback if no internet card - Music: Computronics sound card (generative chiptune), optional Controls W A S D move LShift sprint Space jump Arrows look Mouse drag look Scroll switch block E / B break / place 1 / 2 select block - / = lower / raise render quality M music on/off R random respawn Q quit ]] local component = require("component") local computer = require("computer") local event = require("event") local keyboard = require("keyboard") local unicode = require("unicode") local fs = require("filesystem") if not component.isAvailable("gpu") then error("A graphics card is required") end local gpu = component.gpu local K = keyboard.keys local floor, abs, cos, sin = math.floor, math.abs, math.cos, math.sin local min, max, sqrt, ceil, random = math.min, math.max, math.sqrt, math.ceil, math.random local concat = table.concat --------------------------------------------------------------------- -- Config --------------------------------------------------------------------- local ASSET_DIR = "/home/minecraft/" local ASSETS = { {"loading.bmp", "https://files.catbox.moe/i5nj03.bmp"}, {"grass.bmp", "https://files.catbox.moe/x7tf9n.bmp"}, {"stone.bmp", "https://files.catbox.moe/zs25m6.bmp"}, } -- cols/rows are clipped to what your GPU/screen tier supports. -- sx*sy = rays per character cell (2x4 would be one ray per braille dot). local PRESETS = { {name = "Fast", cols = 80, rows = 25, sx = 1, sy = 2}, {name = "Balanced", cols = 80, rows = 25, sx = 2, sy = 2}, {name = "Sharp", cols = 80, rows = 25, sx = 2, sy = 4}, {name = "Ultra", cols = 160, rows = 50, sx = 2, sy = 2}, } local quality = 2 local VIEW_DIST = 48 -- max DDA steps per ray local FOV = 1.1 local SLICE_IDLE = 0.25 -- CPU seconds per render slice when standing still local SLICE_MOVE = 0.08 -- shorter slices while moving => input polled more often local LOWCONTRAST = 10 -- cells flatter than this become a solid colour local GRAV, JUMP, WALK, SPRINT, TURN = -20.0, 7.5, 4.3, 7.0, 1.9 --------------------------------------------------------------------- -- Helpers --------------------------------------------------------------------- local function clamp(v, a, b) if v < a then return a elseif v > b then return b end return v end local function pack(r, g, b) return floor(r + 0.5) * 65536 + floor(g + 0.5) * 256 + floor(b + 0.5) end --------------------------------------------------------------------- -- Textures --------------------------------------------------------------------- local function u16(s, o) local a, b = s:byte(o, o + 1) return a + b * 256 end local function u32(s, o) local a, b, c, d = s:byte(o, o + 3) return a + b * 256 + c * 65536 + d * 16777216 end -- Loads a 24/32-bit BMP, optionally subsampled so max(w,h) <= maxDim. local function loadBMP(path, maxDim) local f = io.open(path, "rb") if not f then return nil end local data = f:read("*a") f:close() if not data or #data < 54 or data:sub(1, 2) ~= "BM" then return nil end local off = u32(data, 11) + 1 local w = u32(data, 19) local hraw = u32(data, 23) local bpp = u16(data, 29) local comp = u32(data, 31) local topDown = false if hraw >= 2147483648 then hraw = 4294967296 - hraw; topDown = true end local h = hraw if (bpp ~= 24 and bpp ~= 32) or (comp ~= 0 and comp ~= 3) or w < 1 or h < 1 then return nil end local bytes = bpp / 8 local stride = floor((bpp * w + 31) / 32) * 4 local step = max(1, ceil(max(w, h) / (maxDim or 128))) local ow, oh = floor((w + step - 1) / step), floor((h + step - 1) / step) local px = {} for oy = 0, oh - 1 do local y = oy * step -- top-down row in the image local row = topDown and y or (h - 1 - y) -- row index inside the file local base = off + row * stride for ox = 0, ow - 1 do local o = base + ox * step * bytes local b, g, r = data:byte(o, o + 2) px[oy * ow + ox + 1] = r * 65536 + g * 256 + b end end return {w = ow, h = oh, px = px} end local function genTexture(kind) local w, h, px = 16, 16, {} local seed = kind == 1 and 1234 or 4321 local function rnd() seed = (seed * 1103515245 + 12345) % 2147483648 return seed / 2147483648 end for i = 1, w * h do local n = rnd() if kind == 1 then px[i] = pack(55 + n * 35, 135 + n * 60, 40 + n * 30) else local g = 105 + n * 55; px[i] = pack(g, g, g + 4) end end return {w = w, h = h, px = px} end -- 4 pre-shaded variants so rendering is a single table lookup: -- 1 lit top, 2 lit side, 3 shaded (something above), 4 highlighted local function makeVariants(tex) local v1, v2, v3, v4 = {}, {}, {}, {} for i = 1, tex.w * tex.h do local c = tex.px[i] local r, g, b = floor(c / 65536), floor(c / 256) % 256, c % 256 v1[i] = c v2[i] = pack(r * 0.8, g * 0.8, b * 0.8) v3[i] = pack(r * 0.42, g * 0.42, b * 0.46) v4[i] = pack(r + (255 - r) * 0.45, g + (255 - g) * 0.45, b + (255 - b) * 0.2) end tex.v = {v1, v2, v3, v4} end local function download(url, path) if fs.exists(path) then return true end if not component.isAvailable("internet") then return false end local ok = pcall(function() local internet = require("internet") local f = assert(io.open(path, "wb")) local good, err = pcall(function() for chunk in internet.request(url) do f:write(chunk) end end) f:close() if not good then fs.remove(path) error(err) end end) return ok and fs.exists(path) end local textures = {} local BLOCK_NAMES = {"Grass", "Stone"} local selectedBlock = 1 --------------------------------------------------------------------- -- World: sparse edits over an infinite-feeling flat base -- y<4 stone(2), y==4 grass(1), above = air --------------------------------------------------------------------- local MAP_W, MAP_H, MAP_D = 256, 64, 256 local edits, colTop, topY = {}, {}, 4 local base = {} for y = 0, MAP_H - 1 do base[y] = (y == 4 and 1) or (y < 4 and 2) or 0 end local function setBlock(x, y, z, b) local ck = z * 256 + x local key = y * 65536 + ck if b == base[y] then edits[key] = nil else edits[key] = b end if b > 0 then if y > (colTop[ck] or 4) then colTop[ck] = y end if y > topY then topY = y end elseif y == (colTop[ck] or 4) then local ny = y - 1 while ny >= 0 and (edits[ny * 65536 + ck] or base[ny]) == 0 do ny = ny - 1 end colTop[ck] = ny end end local function collides(x, y, z) for ix = floor(x - 0.3), floor(x + 0.3) do for iz = floor(z - 0.3), floor(z + 0.3) do if ix < 0 or ix >= MAP_W or iz < 0 or iz >= MAP_D then return true end for iy = floor(y - 1.4), floor(y) do if iy >= 0 and iy < MAP_H and (edits[iy * 65536 + iz * 256 + ix] or base[iy]) > 0 then return true end end end end return false end --------------------------------------------------------------------- -- Player state --------------------------------------------------------------------- local camX, camY, camZ = 128, 6.5, 128 local lookDir, pitch = 0, 0 local velY, onGround = 0, false local held = {} local dirty, running = true, true local function respawn() camX, camZ, camY, velY = random(20, 230), random(20, 230), 15, 0 dirty = true end local lastLook = -10 local moveKeys = {K.w, K.a, K.s, K.d, K.left, K.right, K.up, K.down, K.space} local function isMoving() if computer.uptime() - lastLook < 0.3 or not onGround then return true end for i = 1, #moveKeys do if held[moveKeys[i]] then return true end end return false end local function physics(dt) dt = min(dt, 0.25) local n = max(1, ceil(dt / 0.025)) local h = dt / n local cL, sL = cos(lookDir), sin(lookDir) local mx, mz = 0, 0 if held[K.w] then mx, mz = mx + cL, mz + sL end if held[K.s] then mx, mz = mx - cL, mz - sL end if held[K.a] then mx, mz = mx + sL, mz - cL end if held[K.d] then mx, mz = mx - sL, mz + cL end local moving = (mx ~= 0 or mz ~= 0) if moving then local sp = (held[K.lshift] and SPRINT or WALK) / sqrt(mx * mx + mz * mz) mx, mz = mx * sp, mz * sp end local turn = (held[K.right] and 1 or 0) - (held[K.left] and 1 or 0) if turn ~= 0 then lookDir = lookDir + turn * TURN * dt; dirty = true end local tilt = (held[K.up] and 1 or 0) - (held[K.down] and 1 or 0) if tilt ~= 0 then pitch = clamp(pitch + tilt * TURN * 0.8 * dt, -1.4, 1.4); dirty = true end local wantJump = held[K.space] for _ = 1, n do if moving then local nx = camX + mx * h if not collides(nx, camY, camZ) then camX = nx; dirty = true end local nz = camZ + mz * h if not collides(camX, camY, nz) then camZ = nz; dirty = true end end if wantJump and onGround then velY = JUMP; onGround = false end velY = velY + GRAV * h local ny = camY + velY * h if collides(camX, ny, camZ) then if velY < 0 then onGround = true end velY = 0 else camY = ny; onGround = false; dirty = true end end if camY < -20 then respawn() end end --------------------------------------------------------------------- -- Block picking (exact DDA). Returns hit cell + the empty cell before it. --------------------------------------------------------------------- local function interactRay(maxDist) local cL, sL, cP, sP = cos(lookDir), sin(lookDir), cos(pitch), sin(pitch) local rdx, rdy, rdz = cL * cP, sP, sL * cP local ax, ay, az = abs(rdx), abs(rdy), abs(rdz) if ax < 1e-9 then ax = 1e-9 end if ay < 1e-9 then ay = 1e-9 end if az < 1e-9 then az = 1e-9 end local dX, dY, dZ = 1 / ax, 1 / ay, 1 / az local sX, sY, sZ = rdx < 0 and -1 or 1, rdy < 0 and -1 or 1, rdz < 0 and -1 or 1 local mX, mY, mZ = floor(camX), floor(camY), floor(camZ) local sdX = rdx < 0 and (camX - mX) * dX or (mX + 1 - camX) * dX local sdY = rdy < 0 and (camY - mY) * dY or (mY + 1 - camY) * dY local sdZ = rdz < 0 and (camZ - mZ) * dZ or (mZ + 1 - camZ) * dZ for _ = 1, 64 do local pX, pY, pZ = mX, mY, mZ local t if sdX < sdY then if sdX < sdZ then t = sdX; sdX = sdX + dX; mX = mX + sX else t = sdZ; sdZ = sdZ + dZ; mZ = mZ + sZ end elseif sdY < sdZ then t = sdY; sdY = sdY + dY; mY = mY + sY else t = sdZ; sdZ = sdZ + dZ; mZ = mZ + sZ end if t > maxDist then return nil end if mX < 0 or mX >= MAP_W or mZ < 0 or mZ >= MAP_D or mY < 0 or mY >= MAP_H then return nil end if (edits[mY * 65536 + mZ * 256 + mX] or base[mY]) > 0 then return mX, mY, mZ, pX, pY, pZ end end end local function breakBlock() local hx, hy, hz = interactRay(8) if hx then setBlock(hx, hy, hz, 0); dirty = true end end local function placeBlock() local hx, hy, hz, px, py, pz = interactRay(8) if not hx then return end if px < 0 or px >= MAP_W or pz < 0 or pz >= MAP_D or py < 0 or py >= MAP_H then return end setBlock(px, py, pz, selectedBlock) if collides(camX, camY, camZ) then setBlock(px, py, pz, 0) return end -- don't entomb yourself dirty = true end --------------------------------------------------------------------- -- Colour handling for braille cells --------------------------------------------------------------------- local depth = gpu.getDepth() local rLev, gLev, bLev = {}, {}, {} for i = 0, 5 do rLev[i] = floor(i * 255 / 5) end for i = 0, 7 do gLev[i] = floor(i * 255 / 7) end for i = 0, 4 do bLev[i] = floor(i * 255 / 4) end -- snap to the tier-3 colour cube so equal colours really are equal on screen local function qCube(r, g, b) return rLev[floor(r * 5 / 255 + 0.5)] * 65536 + gLev[floor(g * 7 / 255 + 0.5)] * 256 + bLev[floor(b * 4 / 255 + 0.5)] end local function qPlain(r, g, b) return pack(r, g, b) end local quant = (depth == 8) and qCube or qPlain local Rc, Gc, Bc, Lc, solidMemo = {}, {}, {}, {}, {} local function cacheColor(c) local r, g, b = floor(c / 65536), floor(c / 256) % 256, c % 256 Rc[c], Gc[c], Bc[c] = r, g, b Lc[c] = 0.299 * r + 0.587 * g + 0.114 * b end local dots, lums, samp, dmap = {0, 0, 0, 0, 0, 0, 0, 0}, {}, {}, {} -- dot k (1..8) = dy*2+dx+1 -> braille bit local bitOf = {1, 8, 2, 16, 4, 32, 64, 128} local chars = {[0] = " "} for b = 1, 255 do chars[b] = unicode.char(0x2800 + b) end -- Split the 8 dot colours into (bright -> ink, dark -> background) local function cluster() local c1 = dots[1] if c1 == dots[2] and c1 == dots[3] and c1 == dots[4] and c1 == dots[5] and c1 == dots[6] and c1 == dots[7] and c1 == dots[8] then local q = solidMemo[c1] if not q then if not Lc[c1] then cacheColor(c1) end q = quant(Rc[c1], Gc[c1], Bc[c1]); solidMemo[c1] = q end return q, q, 0 end local sum = 0 for k = 1, 8 do local c = dots[k] local l = Lc[c] if not l then cacheColor(c); l = Lc[c] end lums[k] = l; sum = sum + l end local mean = sum * 0.125 local fr, fg, fb, nf, fl = 0, 0, 0, 0, 0 local br, bg, bb, nb, bl = 0, 0, 0, 0, 0 local bits = 0 for k = 1, 8 do local c, l = dots[k], lums[k] if l > mean then bits = bits + bitOf[k] fr = fr + Rc[c]; fg = fg + Gc[c]; fb = fb + Bc[c]; nf = nf + 1; fl = fl + l else br = br + Rc[c]; bg = bg + Gc[c]; bb = bb + Bc[c]; nb = nb + 1; bl = bl + l end end if nf == 0 or nb == 0 or fl / nf - bl / nb < LOWCONTRAST then local q = quant((fr + br) * 0.125, (fg + bg) * 0.125, (fb + bb) * 0.125) return q, q, 0 end local f = quant(fr / nf, fg / nf, fb / nf) local b = quant(br / nb, bg / nb, bb / nb) if f == b then return b, b, 0 end return f, b, bits end --------------------------------------------------------------------- -- Screen state + diffed, run-batched output --------------------------------------------------------------------- local cols, rows, gridRows, sx, sy, aspect = 80, 25, 24, 2, 2, 1 local pfg, pbg, pbits = {}, {}, {} local curFg, curBg local runN, runX, runY, runFg, runBg, runInk, runBuf = 0, 0, 0, 0, 0, false, {} local baseSx, baseSy = 2, 2 -- preset sampling; passLod = this pass used the cheap sampling local passLod = false local backBuf -- VRAM back buffer index (nil = draw straight to screen) local minRow, maxRow = 1e9, -1 -- rows touched during the current pass (for partial bitblt) local function flush() if runN > 0 then if runInk and runFg ~= curFg then gpu.setForeground(runFg); curFg = runFg end if runBg ~= curBg then gpu.setBackground(runBg); curBg = runBg end gpu.set(runX, runY, concat(runBuf, "", 1, runN)) runN = 0 end end local function emitRow(row, fill) local y, rowBase = row + 1, row * cols for col = 0, cols - 1 do fill(row, col) local fg, bg, bits = cluster() local idx = rowBase + col + 1 if pfg[idx] == fg and pbg[idx] == bg and pbits[idx] == bits then flush() else pfg[idx], pbg[idx], pbits[idx] = fg, bg, bits if row < minRow then minRow = row end if row > maxRow then maxRow = row end -- blank cells can ride along in any run with the same background if runN > 0 and runBg == bg and (bits == 0 or not runInk or runFg == fg) then runN = runN + 1; runBuf[runN] = chars[bits] if bits ~= 0 and not runInk then runFg = fg; runInk = true end else flush() runN, runX, runY, runFg, runBg, runInk = 1, col + 1, y, fg, bg, (bits ~= 0) runBuf[1] = chars[bits] end end end flush() end local function setColors(fg, bg) if fg ~= curFg then gpu.setForeground(fg); curFg = fg end if bg ~= curBg then gpu.setBackground(bg); curBg = bg end end -- Colour state is tracked per active buffer, so reset the trackers on every switch. local function activate(idx) gpu.setActiveBuffer(idx) curFg, curBg = nil, nil end local function allocBack() if not (gpu.allocateBuffer and gpu.setActiveBuffer and gpu.bitblt) then return nil end local ok, idx = pcall(gpu.allocateBuffer, cols, gridRows) if ok and idx then return idx end return nil -- not enough VRAM (or old OC): fall back to direct drawing end -- Clears the screen and the back buffer and forgets the diff cache. -- Leaves the back buffer (if any) as the active draw target. local function resetFrame() pfg, pbg, pbits = {}, {}, {} if backBuf then activate(0) end curFg, curBg = nil, nil setColors(0xFFFFFF, 0x000000) gpu.fill(1, 1, cols, rows, " ") if backBuf then activate(backBuf) setColors(0xFFFFFF, 0x000000) gpu.fill(1, 1, cols, gridRows, " ") end end -- Copies only the rows that changed this pass from VRAM to the screen in one call. local function presentFrame() if backBuf and maxRow >= minRow then gpu.bitblt(0, 1, minRow + 1, cols, maxRow - minRow + 1, backBuf, 1, minRow + 1) end minRow, maxRow = 1e9, -1 end -- Draw text directly on the real screen (HUD), then go back to the back buffer. local function screenDraw(fn) if backBuf then activate(0) end fn() if backBuf then activate(backBuf) end end local function setSampling(a, b) sx, sy = a, b for k = 1, 8 do local dx, dy = (k - 1) % 2, floor((k - 1) / 2) dmap[k] = floor(dy * sy / 4) * sx + floor(dx * sx / 2) + 1 end end local function applyQuality() if backBuf then pcall(gpu.setActiveBuffer, 0) pcall(gpu.freeBuffer, backBuf) backBuf = nil end local p = PRESETS[quality] local maxW, maxH = gpu.maxResolution() cols, rows = min(p.cols, maxW), min(p.rows, maxH) gpu.setResolution(cols, rows) gridRows = rows - 1 -- last row = HUD baseSx, baseSy = p.sx, p.sy setSampling(baseSx, baseSy) aspect = 2 * gridRows / cols -- braille dots are square backBuf = allocBack() minRow, maxRow = 1e9, -1 resetFrame() end --------------------------------------------------------------------- -- Raycaster (one ray -> one packed RGB colour) --------------------------------------------------------------------- local sCX, sCY, sCZ, fX, fY, fZ = 0, 0, 0, 0, 0, 0 -- per-pass camera snapshot local hlX, hlY, hlZ -- highlighted block local function castRay(rdx, rdy, rdz, skyc) local ax = rdx < 0 and -rdx or rdx local ay = rdy < 0 and -rdy or rdy local az = rdz < 0 and -rdz or rdz if ax < 1e-9 then ax = 1e-9 end if ay < 1e-9 then ay = 1e-9 end if az < 1e-9 then az = 1e-9 end local dX, dY, dZ = 1 / ax, 1 / ay, 1 / az local sX, sY, sZ = rdx < 0 and -1 or 1, rdy < 0 and -1 or 1, rdz < 0 and -1 or 1 local mX, mY, mZ = fX, fY, fZ local sdX = rdx < 0 and (sCX - mX) * dX or (mX + 1 - sCX) * dX local sdY = rdy < 0 and (sCY - mY) * dY or (mY + 1 - sCY) * dY local sdZ = rdz < 0 and (sCZ - mZ) * dZ or (mZ + 1 - sCZ) * dZ local up, top = rdy > 0, topY local ed, bs, ct = edits, base, colTop for _ = 1, VIEW_DIST do local t, side if sdX < sdY then if sdX < sdZ then t = sdX; sdX = sdX + dX; mX = mX + sX; side = 0 else t = sdZ; sdZ = sdZ + dZ; mZ = mZ + sZ; side = 2 end elseif sdY < sdZ then t = sdY; sdY = sdY + dY; mY = mY + sY; side = 1 else t = sdZ; sdZ = sdZ + dZ; mZ = mZ + sZ; side = 2 end if mX < 0 or mX >= MAP_W or mZ < 0 or mZ >= MAP_D or mY < 0 or mY >= MAP_H then return skyc end if up and mY > top then return skyc end -- nothing can be above the tallest block local b = ed[mY * 65536 + mZ * 256 + mX] or bs[mY] if b > 0 then local tex = textures[b] local tw, th = tex.w, tex.h local hx, hy, hz = sCX + t * rdx, sCY + t * rdy, sCZ + t * rdz local u, v if side == 0 then u, v = hz - floor(hz), hy - floor(hy) elseif side == 1 then u, v = hx - floor(hx), hz - floor(hz) else u, v = hx - floor(hx), hy - floor(hy) end local tx = floor(u * tw); if tx >= tw then tx = tw - 1 end local ty = floor((1 - v) * th); if ty >= th then ty = th - 1 end local var if mX == hlX and mY == hlY and mZ == hlZ then var = 4 elseif (ct[mZ * 256 + mX] or 4) >= mY + 2 then var = 3 -- covered from above elseif side ~= 1 then var = 2 else var = 1 end return tex.v[var][ty * tw + tx + 1] end end return skyc end local function skyFor(ry) local t = clamp(ry * 1.2, 0, 1) return pack(170 + (60 - 170) * t, 220 + (140 - 220) * t, 255 + (235 - 255) * t) end local colAx, colAz, rowBx, rowBz, rowRy, rowSky = {}, {}, {}, {}, {}, {} local passActive, passRow, passStart = false, 0, 0 local fps = 0 local function beginPass() -- while moving, cast fewer rays per cell (1x2); refine at full quality once you stop passLod = (baseSx > 1 or baseSy > 2) and isMoving() if passLod then setSampling(1, 2) else setSampling(baseSx, baseSy) end sCX, sCY, sCZ = camX, camY, camZ fX, fY, fZ = floor(sCX), floor(sCY), floor(sCZ) local cL, sL, cP, sP = cos(lookDir), sin(lookDir), cos(pitch), sin(pitch) hlX, hlY, hlZ = interactRay(8) local scols, srows = cols * sx, gridRows * sy for sc = 0, scols - 1 do local vX = ((sc + 0.5) / scols - 0.5) * FOV colAx[sc] = cL * cP - vX * sL colAz[sc] = sL * cP + vX * cL end for sr = 0, srows - 1 do local vY = (0.5 - (sr + 0.5) / srows) * FOV * aspect rowBx[sr] = vY * cL * sP rowBz[sr] = vY * sL * sP local ry = sP + vY * cP rowRy[sr] = ry rowSky[sr] = skyFor(ry) end passRow, passActive, passStart = 0, true, computer.uptime() minRow, maxRow = 1e9, -1 end local function gameCell(row, col) local n, sr0, sc0 = 0, row * sy, col * sx for j = 0, sy - 1 do local sr = sr0 + j local bx, bz, ry, sk = rowBx[sr], rowBz[sr], rowRy[sr], rowSky[sr] for i = 0, sx - 1 do local sc = sc0 + i n = n + 1 samp[n] = castRay(colAx[sc] - bx, ry, colAz[sc] - bz, sk) end end for k = 1, 8 do dots[k] = samp[dmap[k]] end end local function drawHUD() local p = PRESETS[quality] local s = string.format(" %s | Q%d %s | %.1f fps | %s | %d %d %d", BLOCK_NAMES[selectedBlock], quality, p.name, fps, backBuf and "VRAM" or "direct", floor(camX), floor(camY), floor(camZ)) s = s:sub(1, cols) s = s .. string.rep(" ", cols - #s) screenDraw(function() setColors(0xFFFFFF, 0x000000) gpu.set(1, rows, s) -- crosshair on the real screen (the blit overwrites it each pass, so no cache issues) local cx, cy = floor(cols / 2), floor(gridRows / 2) setColors(0xFFFFFF, pbg[cy * cols + cx + 1] or 0x000000) gpu.set(cx + 1, cy + 1, "+") end) end -- Renders rows until the CPU-time budget is used; returns true when the pass is complete. local function renderSlice(budget) local t0 = os.clock() while passRow < gridRows do emitRow(passRow, gameCell) passRow = passRow + 1 if os.clock() - t0 > budget then break end end if passRow >= gridRows then passActive = false return true end return false end --------------------------------------------------------------------- -- Music (Computronics sound card, best effort) --------------------------------------------------------------------- local sound = component.isAvailable("sound") and component.sound or nil local musicOn = sound ~= nil local nextMusic = 0 local NOTE_MS = 280 local melody = {261.63, 293.66, 329.63, 392.00, 440.00, 523.25, 587.33, 659.25} local bassNotes = {130.81, 98.00, 110.00, 87.31} local mIdx, bIdx = 3, 1 local function musicInit() if not sound then return end pcall(function() sound.setTotalVolume(0.6) end) pcall(function() sound.setWave(1, sound.modes.sine) end) pcall(function() sound.setWave(2, sound.modes.triangle) end) pcall(function() sound.setVolume(1, 0.35) end) pcall(function() sound.setVolume(2, 0.45) end) end local function musicStop() if not sound then return end pcall(function() sound.close(1); sound.close(2); sound.clear() end) end local function musicTick(now) if not musicOn or not sound or now < nextMusic then return end local steps = 8 pcall(function() for step = 1, steps do if step % 4 == 1 then sound.setFrequency(2, bassNotes[bIdx]); sound.open(2) bIdx = bIdx % #bassNotes + 1 end if random() > 0.2 then mIdx = clamp(mIdx + random(-2, 2), 1, #melody) sound.setFrequency(1, melody[mIdx]); sound.open(1) sound.delay(NOTE_MS - 50); sound.close(1); sound.delay(50) else sound.delay(NOTE_MS) end if step % 4 == 0 then sound.close(2) end end sound.process() end) nextMusic = now + steps * NOTE_MS / 1000 - 0.1 end --------------------------------------------------------------------- -- Input (event listeners keep working even while the renderer is yielding) --------------------------------------------------------------------- local lastMX, lastMY local function onKeyDown(_, _, _, code) if held[code] then return end -- ignore OS key-repeat for one-shot actions held[code] = true if code == K.q then running = false elseif code == K["1"] then selectedBlock = 1; dirty = true elseif code == K["2"] then selectedBlock = 2; dirty = true elseif code == K.r then respawn() elseif code == K.e then breakBlock() elseif code == K.b then placeBlock() elseif code == K.m then musicOn = sound ~= nil and not musicOn if not musicOn then musicStop() else nextMusic = 0 end elseif code == K.minus or code == K.equals then local nq = clamp(quality + (code == K.equals and 1 or -1), 1, #PRESETS) if nq ~= quality then quality = nq passActive = false applyQuality() dirty = true end end end local function onKeyUp(_, _, _, code) held[code] = nil end local function onTouch(_, _, x, y) lastMX, lastMY = x, y end local function onDrag(_, _, x, y) lastLook = computer.uptime() if lastMX then lookDir = lookDir + (x - lastMX) * 0.05 pitch = clamp(pitch + (lastMY - y) * 0.07, -1.4, 1.4) dirty = true end lastMX, lastMY = x, y end local function onDrop() lastMX, lastMY = nil, nil end local function onScroll(_, _, _, _, dir) selectedBlock = ((selectedBlock - 1 + (dir > 0 and -1 or 1)) % #BLOCK_NAMES) + 1 dirty = true end local listeners = { key_down = onKeyDown, key_up = onKeyUp, touch = onTouch, drag = onDrag, drop = onDrop, scroll = onScroll, } --------------------------------------------------------------------- -- Main --------------------------------------------------------------------- -- Yield once, then drain EVERY queued signal. (os.sleep(0) only consumes one signal per -- tick, so key-repeat/drag floods backed up and key_up arrived late => "sticky" controls.) local function pump(timeout) local name = event.pull(timeout) -- listeners fire for each signal pulled local n = 0 while name and n < 300 do name = event.pull(0) n = n + 1 end end local function showLoading(img) applyQuality() if img then local function fill(row, col) for k = 1, 8 do local dx, dy = (k - 1) % 2, floor((k - 1) / 2) local u = (col + (dx + 0.5) / 2) / cols local v = (row + (dy + 0.5) / 4) / gridRows dots[k] = img.px[floor(v * img.h) * img.w + floor(u * img.w) + 1] end end for row = 0, gridRows - 1 do emitRow(row, fill) end presentFrame() end local help = " WASD move | Arrows/drag look | Space jump | E break | B place | 1/2 block | -/= quality | M music | Q quit - press a key" screenDraw(function() setColors(0xFFFFFF, 0x000000) gpu.set(1, rows, help:sub(1, cols) .. string.rep(" ", max(0, cols - #help))) end) event.pull("key_down") end local function main() if not fs.exists(ASSET_DIR) then fs.makeDirectory(ASSET_DIR) end for _, a in ipairs(ASSETS) do if not fs.exists(ASSET_DIR .. a[1]) then print("Fetching " .. a[1] .. " ...") if not download(a[2], ASSET_DIR .. a[1]) then print(" (unavailable, using fallback)") end end end local function try(name, maxDim) local ok, res = pcall(loadBMP, ASSET_DIR .. name, maxDim) return ok and res or nil end textures[1] = try("grass.bmp", 128) or genTexture(1) textures[2] = try("stone.bmp", 128) or genTexture(2) makeVariants(textures[1]); makeVariants(textures[2]) local loadingImg = try("loading.bmp", 320) musicInit() showLoading(loadingImg) resetFrame() for name, fn in pairs(listeners) do event.listen(name, fn) end local last = computer.uptime() dirty = true while running do local now = computer.uptime() physics(now - last) last = now musicTick(now) if dirty and not passActive then dirty = false; beginPass() end if passActive then if renderSlice(isMoving() and SLICE_MOVE or SLICE_IDLE) then local dur = max(computer.uptime() - passStart, 0.05) fps = fps == 0 and 1 / dur or (fps * 0.7 + 0.3 / dur) presentFrame() drawHUD() if passLod and not isMoving() then dirty = true end -- refine at full quality end pump(0) else pump(0.05) end end end local oW, oH = gpu.getResolution() local ok, err = pcall(main) for name, fn in pairs(listeners) do pcall(event.ignore, name, fn) end musicStop() pcall(function() gpu.setActiveBuffer(0); gpu.freeAllBuffers() end) gpu.setResolution(oW, oH) gpu.setBackground(0x000000) gpu.setForeground(0xFFFFFF) gpu.fill(1, 1, oW, oH, " ") pcall(function() require("term").setCursor(1, 1) end) if not ok and not tostring(err):find("interrupted") then error(err, 0) end