CODE
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Bunny</title> <style> html,body { margin:0; height:100%; background:#333B4F; overflow:hidden; } #wrap { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; } canvas { image-rendering:pixelated; background:#333B4F; } </style> </head> <body> <div id="wrap"><canvas id="screen" width="960" height="540"></canvas></div> <script> // ===== art.js ===== // AUTO-GENERATED by gen-art.js — do not edit by hand. const ART = { "boulder": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAFNJREFUWIVjzPYy/c8wgIBpIC1nYGBgYIExZFU06Grx4zs3GBgYBkEIjDpg1AGjDhh1wKgDRh0w6oBRB4w6YNQBow4YdcCoA+B9Q1hfjd5gwEMAAJzKB8QABayAAAAAAElFTkSuQmCC", "bounce": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAQCAYAAAB3AH1ZAAAAAXNSR0IArs4c6QAAAD5JREFUSIlj/F998D/DAAKmgbScgYGBgQXOkuKhr83PvjAwMAyCEBh1wKgDRh0w6oBRByDqAmjZTG8w4CEAAJjfBzg+acPWAAAAAElFTkSuQmCC", "breakable": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAFJJREFUWIXt17ERABAQRNFl9GAEGtSEXJsCowoiSnCCv9Fl+2azc62WJcN4y3JJCueIKT8tnqNL+mABAAAAAAAAAAAAAAAAAABwf8Pzq72O+QIbTCQIaRJmO6MAAAAASUVORK5CYII=", "bunny_stage0": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAK9JREFUeJzt2LENg0AABEFwB4Tk7r8i54SUAA3gxHo0stgJEYLV6aOfpiRJ8lTz3T/Yt89x9XxZ3/Mv7432uvPj/6ABdIDWADpAawAdoDWADtAaQAdoDaADtAbQAVoD6ABt+G3Lt5udUUbfED3+BDSADtAaQAdoDaADtAbQAVoD6ACtAXSA1gA6QGsAHaA1gA7QGkAHaA2gA7QG0AFaA+gArQF0gNYAOkBrAB2QJNIJo4UMZ3WEv1UAAAAASUVORK5CYII=", "bunny_stage1": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAOFJREFUeJztmLENgkAUQMG4gCW9UzCII9g5g3EGOkZwEKawt2QErDWSEPg/T3PvdZDL3cvLh+KqSkREREqlzj5gfD6mb+8PzbFesy6aXebm/4ABaAEaA9ACNAagBWgMQAvQGIAWoDEALUBjAFqAxgC0AE34bcvczU4U0TdExU+AAWgBGgPQAjQGoAVoDEAL0BiAFqAxAC1AYwBagMYAtACNAWgBGgPQAjT76A1v3X3RuuvlFH30KpyArI2Hc/v23PZD1lGbKH4CDEAL0KT9A371m/+k+AkwAC1AYwBaQESE5AWHURQH9+/9KgAAAABJRU5ErkJggg==", "bunny_stage2": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAQxJREFUeJztmjEOwWAYQEsk5o4Sg8EZDJ2cwhFsLmARi1licwQ3sJk6OIPBZnQEJkObikb755H/vU3T9Ht5+Uj8JImIiIjESif0gPvt8qi6ng7GhdmT66nyvvNoGtSxG/Lh/4ABaAEaA9ACNAagBWgMQAvQ9GiBF8f+sPJ6Gnhu9BtgAFqAxgC0AI0BaAGa1k9b3p0AfWK9O9S6b7tZtuoc/QYYgBagMQAtQGMAWoDGALQAjQFoARoD0AI0P/NtsC7l/xU0JfoNMAAtQGMAWoDGALQATeu/Dtc93V0tZm2P/go3INSD83lWeJ3t81CjGhH9BhiAFqAJ9hnwq+/5MtFvgAFoARoD0AIiIiRPn5Ub9QQ97B0AAAAASUVORK5CYII=", "bunny_stage3": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAASZJREFUeJztmjEKwkAQRVUE65SChYVnsLDyFB7BTrBOIzbWgp1H8AZ2Vik8g4WdpUfQysKwgRBneJH5r1yS3cfnJ2SXdDpCCCGEiErXe4Hn4/ZKjWfDydfa0/sled11PHd17HlO/g8oAFqARgHQAjQKgBagUQC0AE2fFvhwHoyS45nzuuEboABoARoFQAvQKABagMb8tGWd75InO1bsd7mpc/gGKABagMZ9L1AsZ8nx2bHwXroW4RugAGgBGvd3QFue9SrUAOsJN6tFo/u2h5OxST3CN0AB0AI05rvBqv8BrCj/V/Ar4RugAGgBGgVAC9AoAFqAxnwvUPebvumewRo1wGvi8llgW88FwjdAAdACNG7vgLY+82XCN0AB0AI0CoAWEEIIkjf5YiCV7x2xkAAAAABJRU5ErkJggg==", "burrow": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAYAAACtxXToAAAAAXNSR0IArs4c6QAAAPRJREFUeJzt3LENg0AQBdHDogKHjp0hRP9lIMv1QOQW/IKbiTYcjX68y/v1vMbEPLSAZv0d235Ij7/z/ZxjjBZQgAJoAU0BtICmAFpAUwAtoCmAFtAUQAtoCqAFNAXQApoCaAFNAbSApgBaQFMALaApgBbQFEALaAqgBTQF0AKaAmgBTQG0gKYAWkBTAC2gKYAW0BRAC2gKoAU0BdACmgJoAU0BtICmAFpAUwAtoCmAFtAUQAtoCqAFNAXQApoCaAFNAbSApgBaQFMALaApgBbQFEALaAqgBTQF0AKaAmgBTQG0gKYAWkBTAC2gmT7A0nf5ybkBuPYGX7/8zMsAAAAASUVORK5CYII=", "chaser": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAgCAYAAABU1PscAAAAAXNSR0IArs4c6QAAAGJJREFUWIXt2LENgDAMAEEHsUGGYAlWzWAZghlCg6BPipfRX+XSL3cuo50jEtvoBVbt71QPcI0JV4+IH1zAAJoBNANoBtAMoBlAM4BmAM0AmgE0A2gG0Aygfb/R59eYTfoL3FgYBuH60+74AAAAAElFTkSuQmCC", "checkpoint": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAABACAYAAAB7jnWuAAAAAXNSR0IArs4c6QAAAGNJREFUaIHt2TEKgEAMBMBV7gf+H79ncz8QzkLO0kpMM9skRSBD2iz7MUYKs1YuT5I2m629jX2fft61/AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM/HeP5y/075BS5hJAfyFotaqwAAAABJRU5ErkJggg==", "gate": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAABgCAYAAAB8InCYAAAAAXNSR0IArs4c6QAAAHRJREFUaIHt2qENgEAQBMCF0ACC0DOSKmiNICgB1FPCH2JWndvJ6hu2/XhSmLGyPEmmdqzL3LX4vO4kP1gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg+y1vv969U77AC8t5CUrj4D1pAAAAAElFTkSuQmCC", "plate": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAMCAYAAAADFL+5AAAAAXNSR0IArs4c6QAAADtJREFUOI1jPLki6z/DAAKmgbScgYGBgQXG4BKUpavF394/ZmBgGAQhMOqAUQeMOgBeDsDyJb3BgIcAAA3qCL5F38xYAAAAAElFTkSuQmCC", "spike": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAYCAYAAACbU/80AAAAAXNSR0IArs4c6QAAAElJREFUSIljfOEa9Z9hAAHTQFrOwMDAwAJnqCrS1eI/t+8zMDAMghAYdcCoA0YdMOqAUQeMOmDUAaMOGHUAvE0Ia6PRGwx4CAAAXUcIcDA2KnwAAAAASUVORK5CYII=", "spiker": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAFJJREFUWIXt1zEOABAQRNEhEnEL93Jprc4tREXFEaziT7XdvEy3rpW6ZBhvWS5J4Rwxp6fFsw9JHywAAAAAAAAAAAAAAAAAAADub3h+tdcxX2ADfPYJF/trJ00AAAAASUVORK5CYII=", "switch": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAFRJREFUWIVj/HTQ7z/DAAKmgbScgYGBgQXGYOJRpqvF/77chdhLV1uxgFEHjDpg1AGjDhh1wKgDRh0w6oBRB4w6YNQBow4YdQC8bwjrq9EbDHgIAABMzgkT05z1AgAAAABJRU5ErkJggg==", "tech_door": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAABgCAYAAAB8InCYAAAAAXNSR0IArs4c6QAAAHRJREFUaIHt2qERgEAQA8DAMCgKoQLqpwwkRaAwoJ4S/hAbdS470Tds5/6kMGNleZJM7VjnpWvxcV9JfrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA91vefr17p3yBFzNnCV91ejLeAAAAAElFTkSuQmCC", "tileset": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAAgCAYAAAD9qabkAAAAAXNSR0IArs4c6QAAAUNJREFUeJzt3T1OQkEUhuFBQTGRkJuJ0dvCCigMO3ARtlIZ1sAW3IkrsHUH1lIiIZMrwUj8C1SYzCzgnOJ7n4qpvu7ldqd1/3C7C47e59ee8+Hl6dF1fzr6dN1/Hoxd9+9eb1z3h7Mr1/1J3XXdP3JdB+CqffhR9aLpcLNJ2TtWPdP91Gyyd11fmu4vFsvsfR5t/4k+0lv2jv3KdD+tm+x9Gs9M97/SNnsft09M9/9+v7P3Ratjur/a/YQQ+AIApBEAQBgBAIQRAEAYAQCEEQBAGAEAhBEAQBgBAIQRAEAYAQCEEQBAGAEAhBEAQBgBAIQRAEAYAQCEEQBAGAEAhBEAQBgBAIQRAEAYAQCEEQBAGAEAhBEAQBgBAIT93wYsb/VZK2/1WStv9Vkrb/VZK2/1WStv9Vkrb/VZO9zqs8YXACBsD4sNOu4iJ4xLAAAAAElFTkSuQmCC", "zapper": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAEFJREFUSIlj/P847z8DDQETLQ1nYGBgYIGzWGWpa/LvxwwMDHTwwagFoxaMWjBqwagFoxYMDwsQdTK0DqU2oLkPAF8CB2GDMbwdAAAAAElFTkSuQmCC" }; const IMAGES = {}; function loadImages(done) { const names = Object.keys(ART); let left = names.length; if (left === 0) return done(); for (const n of names) { const img = new Image(); img.onload = () => { if (--left === 0) done(); }; img.onerror = () => { if (--left === 0) done(); }; img.src = ART[n]; IMAGES[n] = img; } } if (typeof module !== 'undefined') module.exports = { ART }; // ===== tuning.js ===== const TUNING = { gravity: 2200, fall_gravity_mult: 1.35, max_fall_speed: 900, jump_velocity: 620, jump_cut_mult: 0.45, coyote_time: 0.10, jump_buffer: 0.12, base_speed: 220, momentum_speed: 380, momentum_ramp_time: 1.5, momentum_air_decay: 0.35, accel: 2400, decel: 2800, air_control: 0.65, hop_interval: 0.28, hop_lift: 6.0, dash_speed: 700, dash_time: 0.15, dash_cooldown: 0.35, wall_slide_speed: 110, wall_jump_x: 420, wall_jump_y: 560, wall_jump_lock: 0.12, vault_boost: 420, attack_active_time: 0.16, attack_cancel_after: 0.08, combo_window: 0.45, charge_time: 0.6, spin_time: 0.25, light_damage: 8, heavy_damage: 24, spin_damage: 12, hit_iframes: 0.6, knockback_x: 260, knockback_y: 300, bounce_speed: 780, push_impulse: 6, }; if (typeof module !== 'undefined') module.exports = { TUNING }; // ===== mathutil.js ===== function moveToward(cur, target, maxStep) { if (cur < target) return Math.min(cur + maxStep, target); if (cur > target) return Math.max(cur - maxStep, target); return target; } function lerp(a, b, t) { return a + (b - a) * t; } function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } function sign(x) { return x > 0 ? 1 : (x < 0 ? -1 : 0); } function hypot(dx, dy) { return Math.sqrt(dx * dx + dy * dy); } if (typeof module !== 'undefined') module.exports = { moveToward, lerp, clamp, sign, hypot }; // ===== infection.js ===== function makeInfection() { const THRESHOLDS = [25, 50, 75]; const inf = { value: 0, add(amount) { const crossed = []; if (amount <= 0 || inf.value >= 100) return { crossed, full: inf.value >= 100 }; const old = inf.value; inf.value = Math.min(inf.value + amount, 100); for (const t of THRESHOLDS) if (old < t && inf.value >= t) crossed.push(t); return { crossed, full: inf.value >= 100 }; }, reset() { inf.value = 0; }, stage() { const s = Math.floor(inf.value / 25); return s < 0 ? 0 : (s > 3 ? 3 : s); }, canDash() { return inf.value >= 25; }, }; return inf; } if (typeof module !== 'undefined') module.exports = { makeInfection }; // ===== combo.js ===== function makeCombo(window) { let step = 0, last = -1000; const w = (window === undefined) ? 0.45 : window; return { press(now) { if (now - last > w) step = 0; step = (step % 3) + 1; last = now; return step; }, reset() { step = 0; last = -1000; }, }; } if (typeof module !== 'undefined') module.exports = { makeCombo }; // ===== world-parse.js ===== const TILE = 32, ROW_OFFSET = 4; const TILE_ATLAS = { '#':0, '=':1, 'D':2, 'R':3, 'I':4, 'M':5, 'N':6, 'L':7 }; const SOLID_CHARS = new Set(['#', '=', 'R', 'M']); const SCENE_CHARS = new Set(['P','S','C','^','B','z','O','m','K','s','p','G','T','U','W']); const ZONE_1 = ` = = = = = =####### = = = = = = = = = = = = = = = B = = ######### = = ######### = = ############# = = ############# = ################# = P ^^^ ################# = S C D ####################################### ############### ################################################## ####################################### ############### ################################################## ####################################### ############### ################################################## ####################################### ############### ################################################## `; const ZONE_2 = ` = = = = = = = = = = = = = = = = = = = = RRRRRRRRR = z = RRRRRRR RRRRRRRRR T G R U = K I z z S I O p C R L L L = C RRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR RRRRRRRRRRRRRRRRRRRR `; const ZONE_3 = ` = = = = = = = = = = = m = s = = MMMMM = MMMMM = = N = N = = N = m S N = = m N MMMMMMM MMMMM m N = MMMMM N G MM N MMM N G K L N L N C s L ^^^^^ C N N s N L D W D= ########################## ######################### ######################################## ########################## ######################### ######################################## ########################## ######################### ######################################## ########################## ######################### ######################################## `; const ZONES = [ZONE_1, ZONE_2, ZONE_3]; function zoneLines(zone) { const lines = zone.split('\n'); if (lines.length && lines[0] === '') lines.shift(); if (lines.length && lines[lines.length - 1] === '') lines.pop(); return lines; } function zoneWidth(zone) { return zoneLines(zone).reduce((w, l) => Math.max(w, l.length), 0); } function zoneOffsets(zones) { const out = []; let x = 0; for (const z of zones) { out.push(x); x += zoneWidth(z); } return out; } function parseWorld(zones) { const offsets = zoneOffsets(zones); const tiles = new Map(); // "x,y" -> atlas index (all placed tiles, for drawing) const solid = new Set(); // "x,y" of solid tiles only const entities = []; let maxRows = 0; zones.forEach((zone, zi) => { const lines = zoneLines(zone); maxRows = Math.max(maxRows, lines.length); lines.forEach((line, y) => { for (let x = 0; x < line.length; x++) { const c = line[x]; const cx = x + offsets[zi], cy = y + ROW_OFFSET; if (c in TILE_ATLAS) { tiles.set(cx + ',' + cy, TILE_ATLAS[c]); if (SOLID_CHARS.has(c)) solid.add(cx + ',' + cy); } else if (SCENE_CHARS.has(c)) { entities.push({ char: c, x: cx, y: cy, px: cx * TILE + TILE / 2, py: cy * TILE + TILE / 2 }); } } }); }); const width = offsets[offsets.length - 1] + zoneWidth(zones[zones.length - 1]); const height = maxRows + ROW_OFFSET; const spawnE = entities.find(e => e.char === 'P'); return { tiles, entities, width, height, isSolidAt: (x, y) => solid.has(x + ',' + y), spawn: spawnE ? { px: spawnE.px, py: spawnE.py } : { px: 0, py: 0 }, killY: (maxRows + ROW_OFFSET + 6) * TILE, pxWidth: width * TILE, pxHeight: height * TILE, }; } if (typeof module !== 'undefined') module.exports = { ZONE_1, ZONE_2, ZONE_3, ZONES, TILE, ROW_OFFSET, TILE_ATLAS, SOLID_CHARS, SCENE_CHARS, zoneLines, zoneWidth, zoneOffsets, parseWorld }; // ===== collision.js ===== function _overlapsSolid(x, y, w, h, isSolidAt, tile) { const minTx = Math.floor((x - w / 2) / tile), maxTx = Math.floor((x + w / 2 - 0.001) / tile); const minTy = Math.floor((y - h / 2) / tile), maxTy = Math.floor((y + h / 2 - 0.001) / tile); for (let tx = minTx; tx <= maxTx; tx++) for (let ty = minTy; ty <= maxTy; ty++) if (isSolidAt(tx, ty)) return true; return false; } const EPS = 1e-7; // Scan the tile columns overlapped by [x-w/2, x+w/2) against the row-span // implied by (y,h), and return the first solid column in the direction of // travel (ascending tx if movingPositive, else descending). O(tiles-in-box). function _findBlockCol(movingPositive, x, y, w, h, isSolidAt, tile) { const minTx = Math.floor((x - w / 2) / tile), maxTx = Math.floor((x + w / 2 - 0.001) / tile); const minTy = Math.floor((y - h / 2) / tile), maxTy = Math.floor((y + h / 2 - 0.001) / tile); if (movingPositive) { for (let tx = minTx; tx <= maxTx; tx++) for (let ty = minTy; ty <= maxTy; ty++) if (isSolidAt(tx, ty)) return tx; } else { for (let tx = maxTx; tx >= minTx; tx--) for (let ty = minTy; ty <= maxTy; ty++) if (isSolidAt(tx, ty)) return tx; } return null; } // Same idea, but scanning rows for the Y axis. function _findBlockRow(movingPositive, x, y, w, h, isSolidAt, tile) { const minTx = Math.floor((x - w / 2) / tile), maxTx = Math.floor((x + w / 2 - 0.001) / tile); const minTy = Math.floor((y - h / 2) / tile), maxTy = Math.floor((y + h / 2 - 0.001) / tile); if (movingPositive) { for (let ty = minTy; ty <= maxTy; ty++) for (let tx = minTx; tx <= maxTx; tx++) if (isSolidAt(tx, ty)) return ty; } else { for (let ty = maxTy; ty >= minTy; ty--) for (let tx = minTx; tx <= maxTx; tx++) if (isSolidAt(tx, ty)) return ty; } return null; } function moveAndCollide(box, vx, vy, dt, isSolidAt, tile) { tile = tile || 32; let { x, y, w, h } = box; let onFloor = false, onCeil = false, onWall = false; // X axis — analytic O(1) snap to the blocking column's edge. x += vx * dt; if (_overlapsSolid(x, y, w, h, isSolidAt, tile)) { if (vx > 0) { const blockCol = _findBlockCol(true, x, y, w, h, isSolidAt, tile); if (blockCol !== null) x = blockCol * tile - w / 2 - EPS; } else { const blockCol = _findBlockCol(false, x, y, w, h, isSolidAt, tile); if (blockCol !== null) x = (blockCol + 1) * tile + w / 2 + EPS; } onWall = true; vx = 0; } // Y axis — analytic O(1) snap to the blocking row's edge. y += vy * dt; if (_overlapsSolid(x, y, w, h, isSolidAt, tile)) { if (vy > 0) { const blockRow = _findBlockRow(true, x, y, w, h, isSolidAt, tile); if (blockRow !== null) y = blockRow * tile - h / 2 - EPS; onFloor = true; } else { const blockRow = _findBlockRow(false, x, y, w, h, isSolidAt, tile); if (blockRow !== null) y = (blockRow + 1) * tile + h / 2 + EPS; onCeil = true; } vy = 0; } return { x, y, vx, vy, onFloor, onCeil, onWall }; } function rayHitsSolid(x, y, dirX, len, isSolidAt, tile) { tile = tile || 32; const ex = x + dirX * len; for (let px = Math.min(x, ex); px <= Math.max(x, ex); px += tile / 4) { if (isSolidAt(Math.floor(px / tile), Math.floor(y / tile))) return true; } return isSolidAt(Math.floor(ex / tile), Math.floor(y / tile)); } if (typeof module !== 'undefined') module.exports = { moveAndCollide, rayHitsSolid }; // ===== puzzle-linker.js ===== function linkPuzzles(activatorPositions, gatePositions) { const out = {}; for (let gi = 0; gi < gatePositions.length; gi++) out[gi] = []; for (let ai = 0; ai < activatorPositions.length; ai++) { let best = -1, bestD = Infinity; const a = activatorPositions[ai]; for (let gi = 0; gi < gatePositions.length; gi++) { const g = gatePositions[gi]; const d = Math.hypot(a.x - g.x, a.y - g.y); if (d < bestD) { bestD = d; best = gi; } } if (best >= 0) out[best].push(ai); } return out; } if (typeof module !== 'undefined') module.exports = { linkPuzzles }; // ===== player-physics.js ===== const _M = (typeof require !== 'undefined') ? require('./mathutil.js') : { moveToward, lerp, sign }; function currentMaxSpeed(p, T) { return _M.lerp(T.base_speed, T.momentum_speed, p.momentum); } function runControl(p, dir, dt, control, T) { if (p.control_lock > 0) return p; if (Math.abs(dir) > 0.1) { p.facing = _M.sign(dir); if (_M.sign(dir) !== _M.sign(p.vx) && Math.abs(p.vx) > 10) p.momentum = 0; const target = dir * currentMaxSpeed(p, T) * control; p.vx = _M.moveToward(p.vx, target, T.accel * dt * control); if (p.onFloor && Math.abs(dir) > 0.9 && _M.sign(p.vx) === _M.sign(dir)) p.momentum = Math.min(p.momentum + dt / T.momentum_ramp_time, 1); } else { p.vx = _M.moveToward(p.vx, 0, T.decel * dt); if (p.onFloor) p.momentum = 0; } return p; } function applyGravity(p, dt, T) { const g = T.gravity * (p.vy > 0 ? T.fall_gravity_mult : 1); p.vy = Math.min(p.vy + g * dt, T.max_fall_speed); p.momentum = Math.max(p.momentum - T.momentum_air_decay * dt, 0); return p; } if (typeof module !== 'undefined') module.exports = { currentMaxSpeed, runControl, applyGravity }; // ===== input.js ===== const Input = (function () { const MAP = { a: 'move_left', ArrowLeft: 'move_left', d: 'move_right', ArrowRight: 'move_right', ' ': 'jump', z: 'jump', Shift: 'dash', x: 'dash', j: 'attack', c: 'attack', r: 'restart', F2: 'debug_infect', }; const cur = {}, prev = {}; function norm(e) { if (e.key === ' ') return ' '; if (e.key.length === 1) return e.key.toLowerCase(); return e.key; // ArrowLeft, Shift, F2, etc. } return { install() { addEventListener('keydown', e => { const a = MAP[norm(e)]; if (a) { cur[a] = true; if (a === 'jump' || a === 'attack' || a === 'restart') e.preventDefault(); } }); addEventListener('keyup', e => { const a = MAP[norm(e)]; if (a) cur[a] = false; }); }, update() { for (const k in cur) prev[k] = cur[k]; }, // call AFTER processing a tick snapshot() { }, // no-op placeholder down: a => !!cur[a], pressed: a => !!cur[a] && !prev[a], released: a => !cur[a] && !!prev[a], axis: () => (cur.move_right ? 1 : 0) - (cur.move_left ? 1 : 0), }; })(); if (typeof module !== 'undefined') module.exports = { Input }; // ===== combat.js ===== // combat.js — Sword hitbox (spec §8, §10). Faithful port of Godot // src/combat/hitbox.gd: strike() sizes + positions the hitbox ahead of its // owner (mirrored by facing) and arms it for `active_time` seconds; anything // that overlaps it while active takes damage. Enemies/hazards (Task 14/15) // call `overlaps(targetRect)` against their own hurtbox rect. const HITBOX_OFFSET_X = 30; // spec §8: sword hitbox offset (30,0) in front of owner function makeHitbox() { const hb = { active: false, damage: 0, x: 0, y: 0, w: 0, h: 0, facing: 1, _timer: 0, // hitSet: tracks which targets this swing has already damaged. Owned by // the hitbox (not the caller) so it survives across attack-cancel chains // (e.g. light->cancel->dash->spin) where a NEW strike() can be called // while the previous hitbox instance is still `active` — no false->true // rising edge on `active` occurs in that case, so a rising-edge-based // guard (as game.js used to implement) misses the reset. Clearing it // unconditionally inside strike() guarantees every distinct strike gets // a fresh one-hit-per-target allowance regardless of prior active state. hitSet: new Set(), }; // strike(): (re)arm the hitbox — position it `HITBOX_OFFSET_X * facing` // in front of (ownerX, ownerY), size it, and start its active countdown // (spec §10: light/heavy/spin each pass their own damage/size/activeTime). hb.strike = function (damage, activeTime, sizeW, sizeH, ownerFacing, ownerX, ownerY) { hb.damage = damage; hb.facing = ownerFacing; hb.w = sizeW; hb.h = sizeH; hb.x = ownerX + HITBOX_OFFSET_X * ownerFacing; hb.y = ownerY; hb._timer = activeTime; hb.active = true; hb.hitSet.clear(); }; // update(): count down the active window; deactivates at 0 (hitbox.gd's // `_physics_process` timer -> `monitoring = false`). hb.update = function (dt) { if (hb._timer > 0) { hb._timer -= dt; if (hb._timer <= 0) { hb._timer = 0; hb.active = false; } } }; // rect(): AABB centered at (x,y) with size (w,h) — top-left form for // AABB overlap tests shared with the rest of the port (collision.js etc). hb.rect = function () { return { x: hb.x - hb.w / 2, y: hb.y - hb.h / 2, w: hb.w, h: hb.h }; }; // overlaps(): AABB test vs a target rect {x,y,w,h}. Only true while the // hitbox is active — an expired hitbox never registers a hit. hb.overlaps = function (targetRect) { if (!hb.active || !targetRect) return false; const r = hb.rect(); return r.x < targetRect.x + targetRect.w && r.x + r.w > targetRect.x && r.y < targetRect.y + targetRect.h && r.y + r.h > targetRect.y; }; // hasHit()/markHit(): let a caller do its own overlap test first (e.g. // against a target-specific hurtRect) and only consult/record the // per-swing guard once it knows the hitbox actually overlaps that target. hb.hasHit = function (target) { return hb.hitSet.has(target); }; hb.markHit = function (target) { hb.hitSet.add(target); }; // tryHit(): one-shot convenience — true (and records target) the first // time a given target overlaps this hitbox during the CURRENT swing (since // the last strike() call cleared hitSet); false on every call after that, // and false whenever the hitbox is inactive or doesn't overlap targetRect. hb.tryHit = function (target, targetRect) { if (!hb.active || hb.hitSet.has(target) || !hb.overlaps(targetRect)) return false; hb.hitSet.add(target); return true; }; return hb; } if (typeof module !== 'undefined') module.exports = { makeHitbox, HITBOX_OFFSET_X }; // ===== enemies.js ===== // enemies.js — Chaser + Spiker enemies (spec §12.1–12.3). Faithful port of // Godot src/enemies/{enemy,chaser,spiker}.gd. Depends on browser globals // (loaded before this file by build.js): moveAndCollide, moveToward, sign. // Falls back to require() for the Node test harness (see mathutil/collision // test files for the same pattern). // NOTE: aliased as _ME/_CE (not _M/_C) because build.js concatenates every // src/*.js file into ONE top-level script (bunny.html) — top-level const // names must be globally unique across files. player-physics.js already // owns the top-level name `_M`; a second top-level `const _M` here caused // `SyntaxError: Identifier '_M' has already been declared` in the bundle, // which silently killed the entire game (see task-14 review fix). const _ME = (typeof require !== 'undefined') ? require('./mathutil.js') : { moveToward, sign }; const _CE = (typeof require !== 'undefined') ? require('./collision.js') : { moveAndCollide }; const ENEMY_GRAVITY = 2200; const HIT_FLASH_TIME = 0.15; // spec §12.3 const HIT_KNOCKBACK = 140; // spec §12.3 // Ground-probe: moveAndCollide only flags onFloor on a downward hit; a // resting body has vy≈0 and would flicker off the floor otherwise. Same // approximation used by player.js for Godot's is_on_floor() floor-snap // hysteresis (spec §3 sanctions this as the move_and_slide approximation). function _groundBelow(e, world) { const feetY = e.y + e.h / 2 + 1; const ty = Math.floor(feetY / 32); const lTx = Math.floor((e.x - e.w / 2 + 0.001) / 32); const rTx = Math.floor((e.x + e.w / 2 - 0.001) / 32); for (let tx = lTx; tx <= rTx; tx++) if (world.isSolidAt(tx, ty)) return true; return false; } // takeHit (spec §12.3): health -= dmg; white hit-flash 0.15s; knockback // vx += sign(x - fromX)*140; health<=0 -> dead. Shared by both subtypes. function _takeHit(e, dmg, fromX) { if (e.dead) return; e.health -= dmg; e.flashTimer = HIT_FLASH_TIME; e.vx += _ME.sign(e.x - fromX) * HIT_KNOCKBACK; if (e.health <= 0) { e.health = 0; e.dead = true; } } // contactRect/hurtRect (same rect — Godot's Hurtbox and ContactHazard Areas // share the identical "zone" CollisionShape2D on both chaser.tscn and // spiker.tscn): the sword's overlap test and the player-contact-damage test // both poll this one AABB, centered at (e.x, e.y). function _zoneRect(e) { return { x: e.x - e.zoneW / 2, y: e.y - e.zoneH / 2, w: e.zoneW, h: e.zoneH }; } function _draw(e, ctx, IMAGES) { const img = IMAGES[e.art]; const tint = e.flashTimer > 0 ? { r: 3, g: 3, b: 3, a: Math.min(1, e.flashTimer / HIT_FLASH_TIME) } : null; Renderer.spriteCentered(ctx, img, Math.round(e.x), Math.round(e.y), 1, tint); } function _makeBase(px, py, opts) { const e = { x: px, y: py, vx: 0, vy: 0, w: opts.bodyW, h: opts.bodyH, zoneW: opts.zoneW, zoneH: opts.zoneH, health: opts.maxHealth, maxHealth: opts.maxHealth, dead: false, gravity: ENEMY_GRAVITY, contactDamage: opts.contactDamage, art: opts.art, onFloor: false, flashTimer: 0, }; e.takeHit = function (dmg, fromX) { _takeHit(e, dmg, fromX); }; e.contactRect = function () { return _zoneRect(e); }; e.hurtRect = function () { return _zoneRect(e); }; e.draw = function (ctx, IMAGES) { _draw(e, ctx, IMAGES); }; return e; } // resolveMotion: apply gravity-if-airborne + moveAndCollide + ground-probe. // Shared tail of both subtypes' update() (matches _physics_process order in // chaser.gd/spiker.gd: gravity, then velocity.x set by AI, then move_and_slide). function _resolveMotion(e, dt, world) { if (!e.onFloor) e.vy += e.gravity * dt; const r = _CE.moveAndCollide({ x: e.x, y: e.y, w: e.w, h: e.h }, e.vx, e.vy, dt, world.isSolidAt); e.x = r.x; e.y = r.y; e.vx = r.vx; e.vy = r.vy; e.onFloor = r.onFloor || _groundBelow(e, world); } function _tickFlash(e, dt) { if (e.flashTimer > 0) e.flashTimer = Math.max(0, e.flashTimer - dt); } // ---- Chaser (spec §12.1) ------------------------------------------------ const CHASER_SPEED = 120, CHASER_AGGRO_X = 220, CHASER_AGGRO_Y = 96, CHASER_ACCEL = 900; function makeChaser(px, py) { const e = _makeBase(px, py, { bodyW: 44, bodyH: 28, zoneW: 46, zoneH: 30, maxHealth: 20, contactDamage: 8, art: 'chaser', }); e.update = function (dt, world, player) { if (e.dead) return; _tickFlash(e, dt); let targetVx = 0; if (player) { const dx = player.x - e.x; const dy = player.y - e.y; if (Math.abs(dx) < CHASER_AGGRO_X && Math.abs(dy) < CHASER_AGGRO_Y) { targetVx = _ME.sign(dx) * CHASER_SPEED; } } e.vx = _ME.moveToward(e.vx, targetVx, CHASER_ACCEL * dt); _resolveMotion(e, dt, world); }; return e; } // ---- Spiker (spec §12.2) ------------------------------------------------- const SPIKER_DECEL = 800; function makeSpiker(px, py) { const e = _makeBase(px, py, { bodyW: 28, bodyH: 28, zoneW: 30, zoneH: 30, maxHealth: 30, contactDamage: 10, art: 'spiker', }); e.update = function (dt, world, player) { if (e.dead) return; _tickFlash(e, dt); e.vx = _ME.moveToward(e.vx, 0, SPIKER_DECEL * dt); _resolveMotion(e, dt, world); }; return e; } if (typeof module !== 'undefined') module.exports = { makeChaser, makeSpiker }; // ===== world-objects.js ===== // world-objects.js — First batch of interactable world entities (spec §12.4-12.8, // §12.12): hazards (spike/zapper), bounce pad, checkpoint, breakable, burrow (win). // Faithful port of Godot src/world/{spike,zapper via hazard.gd, bounce, checkpoint, // breakable, burrow}.gd. Depends on browser globals (loaded before this file by // build.js): Renderer, IMAGES, TUNING, TILE. Each accessor below guards with a // `typeof X !== 'undefined'` check (safe — never throws on an undeclared name) // so this file also loads standalone under Node's `require()` for tests, where // none of those globals exist. // // NOTE on naming: build.js concatenates every src/*.js file into ONE top-level // <script> with no per-file scope, so every top-level const/function name here // must be globally unique across the whole bundle (see task-14's `_M`/`_ME` // collision postmortem). Local helpers are prefixed `_WO` to avoid colliding // with `_M` (player-physics.js) / `_ME`/`_CE` (enemies.js) / etc. const _WO_TILE = (typeof TILE !== 'undefined') ? TILE : 32; const _WO_BOUNCE_SPEED = (typeof TUNING !== 'undefined') ? TUNING.bounce_speed : 780; // Task 16b: boulder needs moveToward (mathutil.js) + moveAndCollide // (collision.js). Both files precede world-objects.js in build.js's ORDER, so // in the bundle their top-level function declarations are already hoisted // globals — the `typeof require` branch below only fires under Node's // require() (tests), matching enemies.js's `_ME`/`_CE` pattern. Named // `_WO_MU`/`_WO_COL` (not `_ME`/`_CE`) since every top-level name must stay // unique across the whole concatenated bundle (see file-top note). const _WO_MU = (typeof require !== 'undefined') ? require('./mathutil.js') : { moveToward, sign }; const _WO_COL = (typeof require !== 'undefined') ? require('./collision.js') : { moveAndCollide }; // Shared AABB overlap test (same math as game.js's local rectsOverlap / combat.js's // hitbox.overlaps, duplicated here since game.js's copy is private to its IIFE). function _woRectsOverlap(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } // Player collision AABB (spec §8: 20x40 centered on player.x/y). Duplicated as a // literal here (rather than referencing player.js's PLAYER_W/PLAYER_H) because // world-objects.js loads BEFORE player.js in build.js's ORDER — those consts // aren't declared yet at this file's parse time, and referencing them at call // time from a different file remains fragile across reorders. The values are // spec-fixed (§8), not tunable, so a literal is the honest source of truth here. function _woPlayerRect(player) { return { x: player.x - 10, y: player.y - 20, w: 20, h: 40 }; } // ---- Spike (spec §12.4): hazard, damage 12, zone 28x18 at offset (0,+7). ----- function makeSpike(px, py) { const o = { x: px, y: py, damage: 12 }; o.hazardRect = function () { const cx = px, cy = py + 7; return { x: cx - 14, y: cy - 9, w: 28, h: 18 }; }; o.draw = function (ctx, IMAGES) { Renderer.spriteCentered(ctx, IMAGES['spike'], px, py, 1, null); }; return o; } // ---- Zapper (spec §12.5): hazard, damage 10, zone 22x22 centered. ----------- function makeZapper(px, py) { const o = { x: px, y: py, damage: 10 }; o.hazardRect = function () { return { x: px - 11, y: py - 11, w: 22, h: 22 }; }; o.draw = function (ctx, IMAGES) { Renderer.spriteCentered(ctx, IMAGES['zapper'], px, py, 1, null); }; return o; } // ---- Bounce pad (spec §12.7): zone 28x12 at (0,+10); every tick, if the // player overlaps and isn't already rocketing upward faster than -50, launch // them at -bounce_speed and top up momentum. No cooldown (polled every tick, // matching bounce.gd's _physics_process). ------------------------------------- function makeBounce(px, py) { const o = { x: px, y: py }; o.zoneRect = function () { const cx = px, cy = py + 10; return { x: cx - 14, y: cy - 6, w: 28, h: 12 }; }; o.tryBounce = function (player) { if (player.vy > -50 && _woRectsOverlap(_woPlayerRect(player), o.zoneRect())) { player.vy = -_WO_BOUNCE_SPEED; player.momentum = Math.min(player.momentum + 0.3, 1); return true; } return false; }; o.draw = function (ctx, IMAGES) { Renderer.spriteCentered(ctx, IMAGES['bounce'], px, py, 1, null); }; return o; } // ---- Checkpoint (spec §12.8): zone 24x56 at (0,-12); first touch only sets // respawn + tints. ------------------------------------------------------------ function makeCheckpoint(px, py) { const o = { x: px, y: py, lit: false }; o.zoneRect = function () { const cx = px, cy = py - 12; return { x: cx - 12, y: cy - 28, w: 24, h: 56 }; }; // check(player, onReach): fires onReach(px,py) + lights up exactly once, on // the first tick the player overlaps the zone. Subsequent calls (this tick // or later) are no-ops once lit. o.check = function (player, onReach) { if (o.lit) return false; if (!_woRectsOverlap(_woPlayerRect(player), o.zoneRect())) return false; o.lit = true; if (onReach) onReach(px, py); return true; }; o.draw = function (ctx, IMAGES) { const tint = o.lit ? { r: 0.6, g: 2.0, b: 1.7 } : null; Renderer.spriteCentered(ctx, IMAGES['checkpoint'], px, py, 1, tint); }; return o; } // ---- Breakable (spec §12.6): solid 32x32 while intact; a hit of damage>=20 // (heavy swing, TUNING.heavy_damage=24) shatters it. Exposes tx/ty (its tile // cell, floor(px/32)/floor(py/32)) so game.js can fold it into a combined // isSolidAt() alongside world.isSolidAt() while !broken. ---------------------- function makeBreakable(px, py) { const o = { x: px, y: py, broken: false, tx: Math.floor(px / _WO_TILE), ty: Math.floor(py / _WO_TILE), }; o.solidRect = function () { return { x: px - 16, y: py - 16, w: 32, h: 32 }; }; o.hit = function (dmg) { if (!o.broken && dmg >= 20) o.broken = true; }; o.draw = function (ctx, IMAGES) { if (o.broken) return; Renderer.spriteCentered(ctx, IMAGES['breakable'], px, py, 1, null); }; return o; } // ---- Burrow (spec §12.12): zone 48x80 at (0,-24); reaching it is the win // condition (game.js sets state.won on a true return). ------------------------ function makeBurrow(px, py) { const o = { x: px, y: py }; o.zoneRect = function () { const cx = px, cy = py - 24; return { x: cx - 24, y: cy - 40, w: 48, h: 80 }; }; o.check = function (player) { return _woRectsOverlap(_woPlayerRect(player), o.zoneRect()); }; o.draw = function (ctx, IMAGES) { Renderer.spriteCentered(ctx, IMAGES['burrow'], px, py, 1, null); }; return o; } // ---- Activator helper (Task 16a): shared "on" state + change-notify used by // both switch and plate. `setOn` is a no-op when the value doesn't actually // change, so listeners (gates recomputing `open`) only fire on real toggles. // Mutates+returns the passed-in object rather than wrapping it, so callers // can keep adding their own fields (hurtRect/zoneRect/etc) fluently. ------- function _woMakeActivator(o) { o.on = false; const listeners = []; o.setOn = function (v) { if (o.on === v) return; o.on = v; for (const cb of listeners) cb(o.on); }; o.onToggle = function (cb) { listeners.push(cb); }; return o; } // ---- Switch (Task 16a): an activator that TOGGLES on any sword hit, // regardless of damage (light/spin/heavy all count — unlike breakable's // damage>=20 gate). Hurt zone 24x24 centered. --------------------------------- function makeSwitch(px, py) { const o = { px, py }; _woMakeActivator(o); o.hurtRect = function () { return { x: px - 12, y: py - 12, w: 24, h: 24 }; }; o.hit = function () { o.setOn(!o.on); }; o.draw = function (ctx, IMAGES) { const tint = o.on ? { r: 0.6, g: 1.6, b: 0.8 } : null; Renderer.spriteCentered(ctx, IMAGES['switch'], px, py, 1, tint); }; return o; } // ---- Plate (Task 16a): an activator that LATCHES on first press and stays // on (pressing again while already on is a no-op — no way to turn a plate // back off by pressing it; that's `setOn(false)` from elsewhere, unused for // now). Press zone 28x10 at offset (0,+8) — a shallow strip a player/boulder // walks/rolls onto, not the full sprite footprint. ---------------------------- function makePlate(px, py) { const o = { px, py }; _woMakeActivator(o); o.zoneRect = function () { const cy = py + 8; return { x: px - 14, y: cy - 5, w: 28, h: 10 }; }; o.press = function () { if (o.on) return; o.setOn(true); }; o.draw = function (ctx, IMAGES) { const tint = o.on ? { r: 0.6, g: 1.6, b: 0.8 } : null; Renderer.spriteCentered(ctx, IMAGES['plate'], px, o.on ? py + 4 : py, 1, tint); }; return o; } // ---- Gate (Task 16a): solid 32x96 wall that opens only when every activator // bound to it (via `bind`, from puzzle-linker's nearest-gate assignment) is // on. A gate with zero activators bound never opens (matches puzzle-linker // leaving orphan gates with an empty array — spec: no false positives from an // empty `every()`). `bind` both stores the list AND subscribes to each // activator's onToggle so `open` recomputes live as switches/plates flip, // then recomputes once immediately so a gate whose activators start already // satisfied (e.g. restart() rebuilding everything off) reflects reality // right away rather than waiting for the first toggle. `solidCells()` folds // the gate into game.js's combined isSolidAt() (see game.js:51) the same way // breakable's tx/ty does, but spans multiple rows since a gate is 3 tiles // tall (96/32) vs breakable's single tile. ----------------------------------- function makeGate(px, py) { const o = { px, py, open: false }; let activators = []; function _recompute() { o.open = activators.length > 0 && activators.every(function (a) { return a.on; }); } o.bind = function (arr) { activators = arr; for (let i = 0; i < activators.length; i++) activators[i].onToggle(_recompute); _recompute(); }; o.solidRect = function () { return { x: px - 16, y: py - 48, w: 32, h: 96 }; }; o.solidCells = function () { const cells = []; const tx = Math.floor(px / _WO_TILE); const tyStart = Math.floor((py - 48) / _WO_TILE); const tyEnd = Math.floor((py + 48 - 1) / _WO_TILE); for (let ty = tyStart; ty <= tyEnd; ty++) cells.push({ tx: tx, ty: ty }); return cells; }; o.draw = function (ctx, IMAGES) { if (o.open) { ctx.save(); ctx.globalAlpha = 0.25; Renderer.spriteCentered(ctx, IMAGES['gate'], px, py, 1, null); ctx.restore(); } else { Renderer.spriteCentered(ctx, IMAGES['gate'], px, py, 1, null); } }; return o; } // ---- Tech door (Task 16b, spec §12.9): solid 32x96 wall (SAME geometry as // gate — solidRect/solidCells are identical math) that opens once the // infection meter crosses a caller-supplied threshold (game.js spawns 'T' // at threshold 25, 'U' at threshold 50) — a one-way ratchet in practice // since infection only rises, but expressed as a plain >= comparison (not // latched) to match gate's polled-every-tick style rather than the // activator/onToggle machinery (there's no discrete toggle event to hang a // listener off of here, just a continuously-rising value). -------------------- function makeTechDoor(px, py, threshold) { const o = { px, py, threshold, open: false }; o.solidRect = function () { return { x: px - 16, y: py - 48, w: 32, h: 96 }; }; o.solidCells = function () { const cells = []; const tx = Math.floor(px / _WO_TILE); const tyStart = Math.floor((py - 48) / _WO_TILE); const tyEnd = Math.floor((py + 48 - 1) / _WO_TILE); for (let ty = tyStart; ty <= tyEnd; ty++) cells.push({ tx: tx, ty: ty }); return cells; }; o.update = function (infectionValue) { o.open = infectionValue >= threshold; }; // Draw (Task 17 review fix): open state now matches gate's translucent // look — the WHOLE sprite faded via an outer ctx.globalAlpha=0.25 (same // pattern as makeGate's draw above), not drawn opaque with only an // additive glow on top (the pre-fix look: opaque sprite + teal glow read // as "lit up" rather than "faded open" like the gate). o.draw = function (ctx, IMAGES) { if (o.open) { ctx.save(); ctx.globalAlpha = 0.25; Renderer.spriteCentered(ctx, IMAGES['tech_door'], px, py, 1, { r: 0.4, g: 1.3, b: 1.1, a: 0.25 }); ctx.restore(); } else { Renderer.spriteCentered(ctx, IMAGES['tech_door'], px, py, 1, null); } }; return o; } // ---- Boulder (Task 16b, spec §12.10-ish): APPROXIMATE pushable box. Godot's // original is a RigidBody2D (real physics-engine body pushed by an // AnimatableBody2D/impulse setup); this port has no physics engine, so it's // reimplemented as a kinematic AABB that falls under gravity and slides via // the SAME moveAndCollide() terrain/gate/door collision the player and // enemies use, plus horizontal damping so a push decays to a stop instead of // sliding forever. game.js handles the player<->boulder push/block and // plate-press interactions explicitly (no physics-engine contact events to // hook here) — see game.js's per-tick boulder section for the full // approximation writeup. ------------------------------------------------- const _WO_BOULDER_GRAVITY = 2200; const _WO_BOULDER_MAX_FALL = 900; const _WO_BOULDER_DAMPING = 400; function makeBoulder(px, py) { const o = { x: px, y: py, w: 30, h: 30, vx: 0, vy: 0, onFloor: false }; o.fixedUpdate = function (dt, isSolidAt) { o.vy = Math.min(o.vy + _WO_BOULDER_GRAVITY * dt, _WO_BOULDER_MAX_FALL); const r = _WO_COL.moveAndCollide({ x: o.x, y: o.y, w: o.w, h: o.h }, o.vx, o.vy, dt, isSolidAt, _WO_TILE); o.x = r.x; o.y = r.y; o.vx = r.vx; o.vy = r.vy; o.onFloor = r.onFloor; o.vx = _WO_MU.moveToward(o.vx, 0, _WO_BOULDER_DAMPING * dt); }; o.rect = function () { return { x: o.x - o.w / 2, y: o.y - o.h / 2, w: o.w, h: o.h }; }; o.push = function (dir, speed) { const d = _WO_MU.sign(dir); if (d !== 0) o.vx = d * Math.abs(speed); }; o.draw = function (ctx, IMAGES) { Renderer.spriteCentered(ctx, IMAGES['boulder'], o.x, o.y, 1, null); }; return o; } if (typeof module !== 'undefined') { module.exports = { makeSpike, makeZapper, makeBounce, makeCheckpoint, makeBreakable, makeBurrow, makeSwitch, makePlate, makeGate, makeTechDoor, makeBoulder, }; } // ===== player.js ===== // player.js — Player body + full 7-state machine (spec §8, §9). // Faithful port of Godot src/player/player.gd + states/*.gd. // Depends on browser globals (loaded before this file by build.js): // TUNING, moveAndCollide, rayHitsSolid, runControl, applyGravity, // currentMaxSpeed, makeCombo, makeHitbox, Renderer, IMAGES, TILE. // // Coordinate system is Y-down (Godot): jump => vy negative, gravity adds +, // falling => vy > 0. Every sign here matches the source. const PLAYER_W = 20, PLAYER_H = 40; // collision AABB (spec §8) function makePlayer(world, infection) { const T = TUNING; const combo = makeCombo(T.combo_window); // The player object is also the physics body passed to runControl/applyGravity, // so it must carry {vx, vy, momentum, facing, onFloor, control_lock}. const pl = { x: world.spawn.px, y: world.spawn.py, vx: 0, vy: 0, momentum: 0, facing: 1, coyote: 0, jump_buffer: 0, dash_cooldown: 0, iframes: 0, control_lock: 0, onFloor: false, onCeil: false, onWall: false, // cosmetic / render state spriteAlpha: 1, spriteOffsetY: -8, spriteTint: null, // transient per-tick inputs (set at top of fixedUpdate) _input: null, _now: 0, hurtboxOn: true, // sword hitbox (spec §8, §10). Exposed as `sword` so enemies/hazards // (Task 14/15) can read its rect()/overlaps() to resolve hits. sword: makeHitbox(), }; // ---- wall sensors (spec §8): low ray local (0,+6), high local (0,-18), // each length 14 in `facing` dir, vs solid tiles. ---- function lowRay() { return rayHitsSolid(pl.x, pl.y + 6, pl.facing, 14, world.isSolidAt, TILE); } function highRay() { return rayHitsSolid(pl.x, pl.y - 18, pl.facing, 14, world.isSolidAt, TILE); } // ---- body helpers (mirror player.gd) ---- function dir() { return pl._input ? pl._input.axis() : 0; } function pressed(a) { return pl._input && pl._input.pressed(a); } function released(a) { return pl._input && pl._input.released(a); } function down(a) { return pl._input && pl._input.down(a); } pl.setFacing = function (d) { if (d !== 0 && d !== pl.facing) pl.facing = d; }; pl.doJump = function () { pl.vy = -T.jump_velocity; pl.jump_buffer = 0; pl.coyote = 0; }; pl.canDash = function () { return infection.canDash() && pl.dash_cooldown <= 0; }; // apply_gravity in player.gd is guarded by `if not is_on_floor()`; reproduce // that guard here (our applyGravity helper is unguarded by contract). function gravity(dt) { if (!pl.onFloor) applyGravity(pl, dt, T); } function control(dt, c) { runControl(pl, dir(), dt, c, T); } // strike(): fire the player's real sword hitbox (spec §8, §10). `size` is // `[w, h]` as passed by the states below; the hitbox positions itself // `30*facing` in front of the player (combat.js owns that offset). pl.strike = function (damage, activeTime, size) { pl.sword.strike(damage, activeTime, size[0], size[1], pl.facing, pl.x, pl.y); }; // ============================================================ // STATE MACHINE (spec §9). Initial = Idle. change() runs the // previous state's exit(), then the new state's enter(prev). // ============================================================ const S = {}; // ---- Idle --------------------------------------------------- S.Idle = { physics(dt) { gravity(dt); control(dt, 1.0); if (!pl.onFloor) { fsm.change('Air'); return; } if (pl.jump_buffer > 0) { pl.doJump(); fsm.change('Air'); return; } if (pressed('dash') && pl.canDash()) { fsm.change('Dash'); return; } if (pressed('attack')) { fsm.change('Attack'); return; } if (Math.abs(dir()) > 0.1) fsm.change('Run'); }, }; // ---- Run ---------------------------------------------------- S.Run = { hop_t: 0, enter() { this.hop_t = 0; }, exit() { pl.spriteOffsetY = -8; }, physics(dt) { gravity(dt); control(dt, 1.0); // cosmetic bounding-hop bob (faster/taller at higher momentum) this.hop_t += dt; const cadence = T.hop_interval / (0.6 + 0.8 * pl.momentum); const bob = Math.abs(Math.sin(this.hop_t * Math.PI / cadence)) * T.hop_lift * (0.3 + 0.7 * pl.momentum); pl.spriteOffsetY = -8 - bob; if (!pl.onFloor) { fsm.change('Air'); return; } if (pl.jump_buffer > 0) { pl.doJump(); fsm.change('Air'); return; } if (pressed('dash') && pl.canDash()) { fsm.change('Dash'); return; } if (pressed('attack')) { fsm.change('Attack'); return; } if (Math.abs(dir()) <= 0.1 && Math.abs(pl.vx) < 10) fsm.change('Idle'); }, }; // ---- Air ---------------------------------------------------- S.Air = { physics(dt) { gravity(dt); control(dt, T.air_control); if (released('jump') && pl.vy < 0) pl.vy *= T.jump_cut_mult; // jump-cut if (pl.jump_buffer > 0 && pl.coyote > 0) pl.doJump(); // buffered jump (stays Air) if (pressed('dash') && pl.canDash()) { fsm.change('Dash'); return; } if (pressed('attack')) { fsm.change('Attack'); return; } const pushing = dir() * pl.facing > 0.5; if (pushing && lowRay()) { if (!highRay() && pl.vy > -50) { pl.vy = -T.vault_boost; // ledge vault: pop over the lip } else if (pl.vy > 0) { fsm.change('Wall'); return; // attach to wall } } if (pl.onFloor) fsm.change(Math.abs(dir()) > 0.1 ? 'Run' : 'Idle'); }, }; // ---- Wall --------------------------------------------------- S.Wall = { physics(dt) { // gravity clamped to wall_slide_speed (NOT guarded by onFloor, per wall.gd) pl.vy = Math.min(pl.vy + T.gravity * dt, T.wall_slide_speed); if (pl.jump_buffer > 0) { pl.jump_buffer = 0; pl.vx = -pl.facing * T.wall_jump_x; pl.vy = -T.wall_jump_y; pl.setFacing(-pl.facing); pl.control_lock = T.wall_jump_lock; fsm.change('Air'); return; } if (pl.onFloor) { fsm.change('Idle'); return; } const pushing = dir() * pl.facing > 0.1; // detach uses 0.1 threshold if (!pushing || !lowRay()) { fsm.change('Air'); return; } }, }; // ---- Dash --------------------------------------------------- S.Dash = { t: 0, enter() { this.t = 0; pl.vx = pl.facing * T.dash_speed; pl.vy = 0; pl.hurtboxOn = false; // i-frames via hurtbox disable pl.dash_cooldown = T.dash_cooldown; }, exit() { pl.hurtboxOn = true; }, physics(dt) { this.t += dt; pl.vx = pl.facing * T.dash_speed; pl.vy = 0; // locked, ignore gravity/input if (pressed('attack')) { fsm.change('SpinSlash'); return; } if (this.t >= T.dash_time) { pl.momentum = 1.0; fsm.change(pl.onFloor ? 'Run' : 'Air'); } }, }; // ---- Attack (HOLD -> SLASH) -------------------------------- S.Attack = { phase: 'HOLD', t: 0, enter() { this.phase = 'HOLD'; this.t = 0; }, exit() { pl.spriteTint = null; }, physics(dt) { this.t += dt; gravity(dt); control(dt, 0.4); if (this.phase === 'HOLD') { if (this.t >= T.charge_time) pl.spriteTint = { r: 1.4, g: 1.6, b: 2.2 }; // blue charge if (!down('attack')) this._slash(this.t >= T.charge_time); } else { // SLASH if (this.t >= T.attack_active_time) { this._end(); return; } if (this.t >= T.attack_cancel_after) { if (pl.jump_buffer > 0 && (pl.onFloor || pl.coyote > 0)) { pl.doJump(); this._end(); return; } if (pressed('dash') && pl.canDash()) { fsm.change('Dash'); return; } } } }, _slash(heavy) { this.phase = 'SLASH'; this.t = 0; pl.spriteTint = null; if (heavy) { pl.strike(T.heavy_damage, T.attack_active_time, [56, 48]); } else { const step = combo.press(pl._now); pl.strike(T.light_damage + 2 * (step - 1), T.attack_active_time, [44, 28]); } }, _end() { fsm.change(pl.onFloor ? 'Idle' : 'Air'); }, }; // ---- SpinSlash (dash + attack) ----------------------------- S.SpinSlash = { t: 0, enter() { this.t = 0; pl.hurtboxOn = true; // hurtbox active — can be hit pl.vx = pl.facing * T.dash_speed; pl.vy = 0; pl.strike(T.spin_damage, T.spin_time, [52, 36]); }, physics(dt) { this.t += dt; pl.vx = pl.facing * T.dash_speed; pl.vy = 0; // locked lunge if (this.t >= T.spin_time) { pl.momentum = 1.0; fsm.change(pl.onFloor ? 'Run' : 'Air'); } }, }; const fsm = { current: null, name: '', change(to) { if (fsm.current && fsm.current.exit) fsm.current.exit(); const prev = fsm.name; fsm.current = S[to]; fsm.name = to; if (fsm.current.enter) fsm.current.enter(prev); }, physics(dt) { if (fsm.current) fsm.current.physics(dt); }, }; fsm.change('Idle'); pl.stateName = () => fsm.name; // ============================================================ // TAKE HIT (spec §8 / player.gd take_hit) // ============================================================ pl.takeHit = function (damage, fromX) { if (pl.iframes > 0) return; pl.iframes = T.hit_iframes; infection.add(damage); pl.momentum = 0; let d = Math.sign(pl.x - fromX); if (d === 0) d = -pl.facing; pl.vx = d * T.knockback_x; pl.vy = -T.knockback_y; pl.control_lock = 0.15; fsm.change('Air'); }; // Hazard polling is a STUB this task (no hazards until later tasks, spec §8). function checkHazards() { /* real hazard/hurtbox polling arrives with combat */ } // Ground-probe: our moveAndCollide only flags onFloor on a downward hit, // but a resting body has vy≈0 and would flicker off the floor. Godot's // is_on_floor() has floor-snap hysteresis; reproduce it by probing 1px // below the feet. This is the allowed move_and_slide approximation (spec §3). function groundBelow() { const feetY = pl.y + PLAYER_H / 2 + 1; const ty = Math.floor(feetY / TILE); const lTx = Math.floor((pl.x - PLAYER_W / 2 + 0.001) / TILE); const rTx = Math.floor((pl.x + PLAYER_W / 2 - 0.001) / TILE); for (let tx = lTx; tx <= rTx; tx++) if (world.isSolidAt(tx, ty)) return true; return false; } // ============================================================ // PER-TICK UPDATE (spec §8 order — do not reorder) // ============================================================ pl.fixedUpdate = function (dt, input, now) { pl._input = input; pl._now = now; // 1. decrement timers pl.coyote = Math.max(pl.coyote - dt, 0); pl.jump_buffer = Math.max(pl.jump_buffer - dt, 0); pl.dash_cooldown = Math.max(pl.dash_cooldown - dt, 0); pl.control_lock = Math.max(pl.control_lock - dt, 0); // 2. iframe flicker if (pl.iframes > 0) { pl.iframes = Math.max(pl.iframes - dt, 0); pl.spriteAlpha = (Math.floor(pl.iframes * 20) % 2 === 0) ? 0.4 : 1.0; } else { pl.spriteAlpha = 1.0; } // 3. global jump-buffer set (every tick regardless of state) if (pressed('jump')) pl.jump_buffer = T.jump_buffer; // 4. hazards (STUB) checkHazards(); // 5. current state's physics (sets velocity / may change state) fsm.physics(dt); // 5b. count down the sword hitbox's active window (spec §10) pl.sword.update(dt); // 6. move-and-resolve vs tiles (per-axis) const r = moveAndCollide({ x: pl.x, y: pl.y, w: PLAYER_W, h: PLAYER_H }, pl.vx, pl.vy, dt, world.isSolidAt, TILE); pl.x = r.x; pl.y = r.y; pl.vx = r.vx; pl.vy = r.vy; pl.onCeil = r.onCeil; pl.onWall = r.onWall; pl.onFloor = r.onFloor || groundBelow(); // 7. push boulder — handled in game.js right after player.fixedUpdate() // returns (needs worldObjects.boulders, which this function has no // access to); see Task 16b's player<->boulder section there. // 8. refresh coyote while grounded if (pl.onFloor) pl.coyote = T.coyote_time; }; // Reset to a spawn/checkpoint position (used by restart / fall-death). pl.respawnAt = function (px, py) { pl.x = px; pl.y = py; pl.vx = 0; pl.vy = 0; pl.momentum = 0; pl.coyote = 0; pl.jump_buffer = 0; pl.dash_cooldown = 0; pl.iframes = 0; pl.control_lock = 0; pl.spriteAlpha = 1; pl.spriteOffsetY = -8; pl.spriteTint = null; pl.sword.active = false; combo.reset(); fsm.change('Idle'); }; // ============================================================ // DRAW (sprite chosen by infection stage; flipped by facing; // offset (0,-8) plus cosmetic run-bob; iframe flicker alpha) // ============================================================ pl.draw = function (ctx) { const img = IMAGES['bunny_stage' + infection.stage()]; ctx.save(); ctx.globalAlpha = pl.spriteAlpha; Renderer.spriteCentered(ctx, img, Math.round(pl.x), Math.round(pl.y + pl.spriteOffsetY), pl.facing, pl.spriteTint); ctx.restore(); }; return pl; } if (typeof module !== 'undefined') module.exports = { makePlayer, PLAYER_W, PLAYER_H }; // ===== camera.js ===== function makeCamera(worldPxW, worldPxH, viewW, viewH) { viewW = viewW || 960; viewH = viewH || 540; const cam = { x: viewW / 2, y: viewH / 2, follow(tx, ty, dt) { const s = Math.min(1, 8.0 * dt); // smoothing speed 8.0 cam.x += (tx - cam.x) * s; cam.y += (ty - cam.y) * s; cam.x = Math.max(viewW / 2, Math.min(worldPxW - viewW / 2, cam.x)); cam.y = Math.max(viewH / 2, Math.min(worldPxH - viewH / 2, cam.y)); }, apply(ctx) { ctx.translate(Math.round(viewW / 2 - cam.x), Math.round(viewH / 2 - cam.y)); }, }; return cam; } if (typeof module !== 'undefined') module.exports = { makeCamera }; // ===== renderer.js ===== const Renderer = { drawTiles(ctx, world, IMAGES, TILE) { const atlas = IMAGES['tileset']; if (!atlas) return; for (const [key, idx] of world.tiles) { const [cx, cy] = key.split(',').map(Number); ctx.drawImage(atlas, idx * TILE, 0, TILE, TILE, cx * TILE, cy * TILE, TILE, TILE); } }, spriteCentered(ctx, img, px, py, flip, tint) { if (!img) return; ctx.save(); ctx.translate(Math.round(px), Math.round(py)); if (flip === -1) ctx.scale(-1, 1); const w = img.width, h = img.height; ctx.drawImage(img, -w / 2, -h / 2); if (tint) { // multiplicative brighten approximation ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = tint.a !== undefined ? tint.a : 0.4; ctx.fillStyle = `rgb(${Math.min(255, tint.r * 120)},${Math.min(255, tint.g * 120)},${Math.min(255, tint.b * 120)})`; ctx.fillRect(-w / 2, -h / 2, w, h); } ctx.restore(); }, }; if (typeof module !== 'undefined') module.exports = { Renderer }; // ===== hud.js ===== // hud.js — Screen-space UI (spec §13). Faithful port of Godot src/ui/hud.gd // + hud.tscn. Runs every frame in SCREEN space (after the world/camera // transform is reset), including while the game is frozen (game-over/win), // since HUD.gd's CanvasLayer has process_mode=3 (always). // // Depends on nothing but the 2D canvas context; infection/state are passed // in by game.js each frame. const HUD = { BAR_X: 16, BAR_Y: 16, BAR_W: 300, BAR_H: 22, BAR_MAX: 100, BAR_TINT: 'rgb(89,229,199)', // spec §13: modulate (0.35, 0.9, 0.78) VERSION: 'v0.3.1', GAME_OVER_COLOR: 'rgba(5,5,13,0.8)', // spec §13: Color(0.02,0.02,0.05,0.8) WIN_COLOR: 'rgba(5,23,18,0.85)', // spec §13: Color(0.02,0.09,0.07,0.85) GAME_OVER_TEXT: 'Consciousness fades.\nPress R to return to an earlier memory.', WIN_TEXT: 'The burrow. You made it.\nSomewhere inside, your family is waiting.\nPress R to run it again.', // draw(ctx, infection, state): call after ctx.setTransform(1,0,0,1,0,0) // (screen space, no camera translation). `infection` is a makeInfection() // instance; `state` carries `{ won }` (set by the burrow, Task 15). draw(ctx, infection, state) { const w = ctx.canvas.width, h = ctx.canvas.height; // ---- infection bar: top-left offset (16,16), 300x22, max 100, teal // fill proportional to value, no % text (spec §7, §13). ---- ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(HUD.BAR_X, HUD.BAR_Y, HUD.BAR_W, HUD.BAR_H); const frac = Math.max(0, Math.min(1, infection.value / HUD.BAR_MAX)); ctx.fillStyle = HUD.BAR_TINT; ctx.fillRect(HUD.BAR_X, HUD.BAR_Y, HUD.BAR_W * frac, HUD.BAR_H); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.strokeRect(HUD.BAR_X + 0.5, HUD.BAR_Y + 0.5, HUD.BAR_W - 1, HUD.BAR_H - 1); ctx.restore(); // ---- version label, bottom-right ---- ctx.save(); ctx.fillStyle = '#ffffff'; ctx.font = '12px monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText(HUD.VERSION, w - 12, h - 10); ctx.restore(); // ---- game-over overlay: infection.value >= 100 (spec §7, §13) ---- if (infection.value >= 100) { HUD._overlay(ctx, w, h, HUD.GAME_OVER_COLOR, HUD.GAME_OVER_TEXT); } // ---- win overlay: state.won (spec §12.12, §13) ---- if (state && state.won) { HUD._overlay(ctx, w, h, HUD.WIN_COLOR, HUD.WIN_TEXT); } }, // _overlay(): full-screen fill + centered multi-line white text. _overlay(ctx, w, h, color, text) { ctx.save(); ctx.fillStyle = color; ctx.fillRect(0, 0, w, h); ctx.fillStyle = '#ffffff'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const lines = text.split('\n'); const lineHeight = 28; const startY = h / 2 - ((lines.length - 1) * lineHeight) / 2; lines.forEach((line, i) => ctx.fillText(line, w / 2, startY + i * lineHeight)); ctx.restore(); }, }; if (typeof module !== 'undefined') module.exports = { HUD }; // ===== game.js ===== // game.js — Main loop: fixed-timestep 60 Hz accumulator (spec §4). // All movement constants are per-tick @ 60 Hz, so updates MUST run at a fixed // dt = 1/60 via an accumulator; render draws whatever the latest state is. (function () { const canvas = document.getElementById('screen'); const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false; Input.install(); // Task 17: letterbox fit-scaling. The drawing buffer stays a fixed // 960x540 (all game-space math elsewhere assumes those pixel // coordinates); only the canvas ELEMENT's on-screen CSS size changes, // to the largest 16:9 box that fits inside the window without // cropping/stretching. #wrap (index.template.html) centers whatever // size results. Recomputed on load and on every resize; pixelated // image-rendering (CSS) + imageSmoothingEnabled=false (above) keep // scaled-up pixels crisp instead of blurred. function fitCanvas() { const targetRatio = 960 / 540; const w = window.innerWidth, h = window.innerHeight; let cssW, cssH; if (w / h > targetRatio) { cssH = h; cssW = h * targetRatio; } else { cssW = w; cssH = w / targetRatio; } canvas.style.width = Math.floor(cssW) + 'px'; canvas.style.height = Math.floor(cssH) + 'px'; } fitCanvas(); window.addEventListener('resize', fitCanvas); // AABB overlap test shared by the sword->enemy and enemy->player contact // checks below (same math as combat.js's hitbox.overlaps, generalized to // two arbitrary rects since neither side here is the sword hitbox itself). function rectsOverlap(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } loadImages(function () { const world = parseWorld(ZONES); const infection = makeInfection(); // Task 15: world objects spawned from the ASCII world (^=spike, z=zapper, // m=bounce, K=checkpoint, B=breakable, W=burrow). Task 16a adds s=switch, // p=plate, G=gate. Task 16b adds T/U=tech door (threshold 25/50) and // O=boulder. Rebuilt (not reset) on restart() below, same rationale // as spawnEnemies() — a fresh scene load brings broken/lit/on/open state // back to spawn defaults. function spawnWorldObjects() { const spikes = [], zappers = [], bounces = [], checkpoints = [], breakables = []; const switches = [], plates = [], gates = [], techDoors = [], boulders = []; let burrow = null; for (const e of world.entities) { switch (e.char) { case '^': spikes.push(makeSpike(e.px, e.py)); break; case 'z': zappers.push(makeZapper(e.px, e.py)); break; case 'm': bounces.push(makeBounce(e.px, e.py)); break; case 'K': checkpoints.push(makeCheckpoint(e.px, e.py)); break; case 'B': breakables.push(makeBreakable(e.px, e.py)); break; case 'W': burrow = makeBurrow(e.px, e.py); break; case 's': switches.push(makeSwitch(e.px, e.py)); break; case 'p': plates.push(makePlate(e.px, e.py)); break; case 'G': gates.push(makeGate(e.px, e.py)); break; case 'T': techDoors.push(makeTechDoor(e.px, e.py, 25)); break; case 'U': techDoors.push(makeTechDoor(e.px, e.py, 50)); break; case 'O': boulders.push(makeBoulder(e.px, e.py)); break; } } return { spikes, zappers, bounces, checkpoints, breakables, burrow, switches, plates, gates, techDoors, boulders, }; } let worldObjects = spawnWorldObjects(); // Puzzle wiring (Task 16a): each gate binds to the activators (switches + // plates, in that stable concat order) puzzle-linker.js's nearest-gate // assignment assigned it, so a gate opens once ALL of ITS activators are // on. Re-run after every spawnWorldObjects() (initial load + restart()) // since that rebuild makes all-new activator/gate objects with fresh // onToggle listener lists — stale bindings from the old objects would // otherwise dangle. function wirePuzzles() { const activators = worldObjects.switches.concat(worldObjects.plates); const links = linkPuzzles( activators.map(function (a) { return { x: a.px, y: a.py }; }), worldObjects.gates.map(function (g) { return { x: g.px, y: g.py }; }) ); for (let gi = 0; gi < worldObjects.gates.length; gi++) { worldObjects.gates[gi].bind(links[gi].map(function (ai) { return activators[ai]; })); } } wirePuzzles(); // Breakable collision (spec §12.6): while intact, a breakable's tile cell // is solid, same as any wall tile. `world.isSolidAt` is read fresh on every // call by player.js (lowRay/highRay/fixedUpdate's moveAndCollide) and by // enemies.js (_resolveMotion), so overwriting the property here — rather // than threading a new function through those files' signatures — is the // only change needed to fold breakables into everyone's collision. The // closure captures the *variable* `worldObjects` (not a snapshot), so a // restart() reassignment is picked up automatically. // Task 16a: a gate is solid (blocks movement) while closed and folds into // this same combined test — a gate's footprint spans 3 tile rows // (solidCells()), unlike breakable's single tx/ty cell, so it's checked // via a small array-search rather than a direct field compare. const _worldBaseIsSolidAt = world.isSolidAt; world.isSolidAt = function (tx, ty) { if (_worldBaseIsSolidAt(tx, ty)) return true; const breakables = worldObjects.breakables; for (let i = 0; i < breakables.length; i++) { const b = breakables[i]; if (!b.broken && b.tx === tx && b.ty === ty) return true; } const gates = worldObjects.gates; for (let i = 0; i < gates.length; i++) { const g = gates[i]; if (g.open) continue; const cells = g.solidCells(); for (let j = 0; j < cells.length; j++) { if (cells[j].tx === tx && cells[j].ty === ty) return true; } } // Task 16b: closed tech-doors are solid the same way (open ones are // passable, so skipped entirely, same short-circuit as gates above). const techDoors = worldObjects.techDoors; for (let i = 0; i < techDoors.length; i++) { const td = techDoors[i]; if (td.open) continue; const cells = td.solidCells(); for (let j = 0; j < cells.length; j++) { if (cells[j].tx === tx && cells[j].ty === ty) return true; } } return false; }; const player = makePlayer(world, infection); const cam = makeCamera(world.pxWidth, world.pxHeight); // Task 14: enemies spawned from the ASCII world (C=chaser, S=spiker). // Rebuilt (not just reset) on restart() below, matching a full scene // reload in Godot — dead/displaced enemies come back at their spawn tiles. function spawnEnemies() { return world.entities .filter(e => e.char === 'C' || e.char === 'S') .map(e => (e.char === 'C' ? makeChaser(e.px, e.py) : makeSpiker(e.px, e.py))); } let enemies = spawnEnemies(); // Checkpoint respawn point (spec §12.8; full fall-death/respawn loop lands // in Task 16). Defaults to world spawn; a checkpoint's onReach callback // below moves it forward as zones are cleared. let respawn = { x: world.spawn.px, y: world.spawn.py }; // Sword one-hit-per-swing guard: the sword hitbox (combat.js) stays // `active` for its whole active_time window (~10 ticks @0.16s), so // without a guard an enemy would take damage every tick it overlaps // (Godot's Area2D area_entered fires once on enter). The guard itself // lives on the hitbox (combat.js's hitSet/tryHit, cleared unconditionally // inside every strike()) rather than here as a rising-edge check on // `sword.active`, because attack-cancel chains (light -> cancel -> dash // -> spin, player.js) can call strike() again while the PREVIOUS hitbox // was already active — no false->true edge occurs, so a rising-edge // guard would leave already-tagged enemies wrongly immune to the new // spin swing. // `state.won` stays false until the burrow (Task 15) sets it on player // entry; kept here so HUD.draw / the freeze check have somewhere to read // it from now. const state = { won: false }; const DT = 1 / 60; let acc = 0; let last = performance.now(); let gameClock = 0; // monotonic game-seconds, for combo timing function restart() { infection.reset(); worldObjects = spawnWorldObjects(); wirePuzzles(); respawn = { x: world.spawn.px, y: world.spawn.py }; player.respawnAt(world.spawn.px, world.spawn.py); enemies = spawnEnemies(); gameClock = 0; state.won = false; } function frame(now) { let frameDt = (now - last) / 1000; last = now; if (frameDt > 0.25) frameDt = 0.25; // clamp to avoid spiral-of-death acc += frameDt; while (acc >= DT) { // restart (R) always processes, even while frozen: reset infection // to 0 + respawn to spawn (spec §7, §13). if (Input.pressed('restart')) restart(); // Freeze player/world updates once fully infected or on win (spec // §7 "100 = game over"; §12.12 "on player enter -> win (freeze...)"). // The HUD itself keeps drawing every frame regardless (see below). const frozen = infection.value >= 100 || state.won; // debug_infect (F2): +10 infection, for testing dash-unlock/game-over // without playing through hazards (spec §7). if (!frozen && Input.pressed('debug_infect')) infection.add(10); if (!frozen) { gameClock += DT; player.fixedUpdate(DT, Input, gameClock); const playerRect = { x: player.x - PLAYER_W / 2, y: player.y - PLAYER_H / 2, w: PLAYER_W, h: PLAYER_H, }; // Task 16b: tech doors react to the live infection value every // tick (no discrete toggle event — see world-objects.js's // makeTechDoor comment), then boulders fall/slide against terrain // + closed gates/doors via the same combined world.isSolidAt() // used everywhere else. Boulder is NOT folded into isSolidAt // itself (it moves, unlike breakables/gates/doors) — its // collision with the player is handled explicitly right after. for (const td of worldObjects.techDoors) td.update(infection.value); for (const b of worldObjects.boulders) b.fixedUpdate(DT, world.isSolidAt); // Task 16b: player<->boulder push+block (APPROXIMATE — Godot's // boulder is a real RigidBody2D pushed by physics-engine contact; // here we just: (1) shove the boulder away from the player's // center along X and give it velocity in that direction so it // visibly "rolls" when walked into, and (2) clamp the player back // out to the boulder's near edge on X so it still reads as solid // from the player's side, without a full two-body physics solve). for (const b of worldObjects.boulders) { if (rectsOverlap(playerRect, b.rect())) { const dir = _M.sign(b.x - player.x) || player.facing; b.push(dir, Math.max(Math.abs(player.vx), 120)); const half = PLAYER_W / 2 + b.w / 2; player.x = dir > 0 ? b.x - half : b.x + half; player.vx = 0; } } // Task 16b: plates also latch when a boulder rests/rolls onto // their zone (this is how the Zone-2 gate opens without a // player standing on the plate) — in addition to 16a's // player-on-plate press below. for (const pl of worldObjects.plates) { for (const b of worldObjects.boulders) { if (rectsOverlap(b.rect(), pl.zoneRect())) pl.press(); } } for (const e of enemies) { if (e.dead) continue; e.update(DT, world, player); if (!e.dead && player.sword.tryHit(e, e.hurtRect())) { e.takeHit(player.sword.damage, player.sword.x); } if (!e.dead && player.hurtboxOn && player.iframes <= 0 && rectsOverlap(e.contactRect(), playerRect)) { player.takeHit(e.contactDamage, e.x); } } // Task 15: world objects — hazards, bounce, checkpoint, breakable, // burrow (spec §12.4-12.8, §12.12). for (const hz of worldObjects.spikes) { // hurtbox is disabled during Dash (spec §8), so dash i-frames cover hazards too if (player.hurtboxOn && player.iframes <= 0 && rectsOverlap(hz.hazardRect(), playerRect)) { player.takeHit(hz.damage, hz.x); } } for (const hz of worldObjects.zappers) { if (player.hurtboxOn && player.iframes <= 0 && rectsOverlap(hz.hazardRect(), playerRect)) { player.takeHit(hz.damage, hz.x); } } for (const b of worldObjects.bounces) b.tryBounce(player); for (const c of worldObjects.checkpoints) { c.check(player, (x, y) => { respawn = { x, y }; }); } for (const brk of worldObjects.breakables) { if (!brk.broken && player.sword.tryHit(brk, brk.solidRect())) { brk.hit(player.sword.damage); } } if (worldObjects.burrow && worldObjects.burrow.check(player)) { state.won = true; } // Task 16a: switches toggle on any sword hit; plates latch on when // the player stands in their zone. Gates recompute `open` // automatically via the onToggle listeners wirePuzzles() attached // through bind() — nothing to poll for gates here. (Boulder-on- // plate handled above, in the Task 16b boulder section.) for (const sw of worldObjects.switches) { if (player.sword.tryHit(sw, sw.hurtRect())) sw.hit(); } for (const pl of worldObjects.plates) { if (rectsOverlap(playerRect, pl.zoneRect())) pl.press(); } // Task 16b: fall-death/respawn (spec §7/§13-ish — falling past the // bottom of the map is a hazard like any other: takes damage, then // snaps back to the last checkpoint/spawn). world.killY is set by // world-parse.js from the map's row count; `+1` on the damage // source x just biases the knockback-facing calc away from // dead-center (matches hazard call sites elsewhere that pass a // non-equal fromX so sign() never lands on 0). if (player.y > world.killY) { player.takeHit(10, player.x + 1); player.x = respawn.x; player.y = respawn.y; player.vx = 0; player.vy = 0; } } Input.update(); // latch edge state AFTER the tick acc -= DT; } cam.follow(player.x, player.y, DT); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#333B4F'; ctx.fillRect(0, 0, 960, 540); cam.apply(ctx); Renderer.drawTiles(ctx, world, IMAGES, TILE); // World objects draw between tiles and enemies/player (spec has no // explicit stacking order for these; behind the player reads fine). for (const s of worldObjects.spikes) s.draw(ctx, IMAGES); for (const z of worldObjects.zappers) z.draw(ctx, IMAGES); for (const b of worldObjects.bounces) b.draw(ctx, IMAGES); for (const c of worldObjects.checkpoints) c.draw(ctx, IMAGES); for (const brk of worldObjects.breakables) brk.draw(ctx, IMAGES); if (worldObjects.burrow) worldObjects.burrow.draw(ctx, IMAGES); for (const sw of worldObjects.switches) sw.draw(ctx, IMAGES); for (const pl of worldObjects.plates) pl.draw(ctx, IMAGES); for (const g of worldObjects.gates) g.draw(ctx, IMAGES); for (const td of worldObjects.techDoors) td.draw(ctx, IMAGES); for (const b of worldObjects.boulders) b.draw(ctx, IMAGES); for (const e of enemies) if (!e.dead) e.draw(ctx, IMAGES); player.draw(ctx); // HUD draws in screen space (transform reset), on top of the world, // and runs every frame even while frozen (spec §13). ctx.setTransform(1, 0, 0, 1, 0, 0); HUD.draw(ctx, infection, state); requestAnimationFrame(frame); } requestAnimationFrame(frame); }); })(); </script> </body> </html>
PREVIEW
PICTURES0/50
No pictures yet. Add one, then ask your buddy to put it in your app!