From 063ace998f3b3d2bb573c8aa63211dea5f56bf96 Mon Sep 17 00:00:00 2001 From: Quintine Date: Tue, 11 Aug 2026 08:51:43 +1000 Subject: [PATCH 1/2] feat: big rooms with Isaac-style camera, extra-large and odd rooms --- js/debug.js | 4 ++-- js/items.js | 2 +- js/main.js | 8 +++++-- js/player.js | 2 +- js/rooms.js | 42 ++++++++++++++++++++++++++---------- js/state.js | 1 + js/weapons.js | 6 +++--- test/smoke.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 104 insertions(+), 21 deletions(-) diff --git a/js/debug.js b/js/debug.js index 8bf6533..7fc16f6 100644 --- a/js/debug.js +++ b/js/debug.js @@ -72,7 +72,7 @@ function dbgClicked(rect) { return Input.mpressed && inRect(Input.mx, Input.my, // clamp the cursor to the arena so spawned pickups/pedestals never land in walls function dbgCursor() { const a = G.arena; - return { x: clamp(Input.mx, a.x0 + 12, a.x1 - 12), y: clamp(Input.my, a.y0 + 12, a.y1 - 12) }; + return { x: clamp(mxW(), a.x0 + 12, a.x1 - 12), y: clamp(myW(), a.y0 + 12, a.y1 - 12) }; } // number row: label + [-] value [+] @@ -185,7 +185,7 @@ function dbgRenderPlayer(ctx, b) { { t: 'KILL PLAYER', fn: () => { p.hp = 0; gameOver(); } }, { t: '+1 LEVELUP', fn: () => { G.pendingLevelups++; addToast('DEBUG', '+1 pending levelup'); } }, { t: 'OPEN DRAFT', fn: () => { openPerkDraft(); } }, - { t: 'TP TO CURSOR', fn: () => { p.x = clamp(Input.mx, G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(Input.my, G.arena.y0 + p.r, G.arena.y1 - p.r); } }, + { t: 'TP TO CURSOR', fn: () => { p.x = clamp(mxW(), G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(myW(), G.arena.y0 + p.r, G.arena.y1 - p.r); } }, { t: '+500 SCORE', fn: () => addScore(500) }, ]; for (let i = 0; i < acts.length; i++) { diff --git a/js/items.js b/js/items.js index 4c6f3e2..a99ba11 100644 --- a/js/items.js +++ b/js/items.js @@ -198,7 +198,7 @@ const ACTIVES = { spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8); } }, offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) { - explodeAt(Input.mx, Input.my, 95, 25 * p.stats.dmgMul, true); + explodeAt(mxW(), myW(), 95, 25 * p.stats.dmgMul, true); } }, bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) { p.xp = Math.floor(p.xp * 0.75); diff --git a/js/main.js b/js/main.js index 3b4796a..10c7ab1 100644 --- a/js/main.js +++ b/js/main.js @@ -298,8 +298,11 @@ function draw() { if (G.mode === 'menu') { drawMenu(ctx); return; } - // world (with screen shake) + updateCamera(); + + // world (with camera + screen shake) ctx.save(); + ctx.translate(-G.cam.x, -G.cam.y); if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5); drawRoom(ctx); drawCorpses(ctx); @@ -309,6 +312,7 @@ function draw() { drawBullets(ctx); drawParticles(ctx); drawTelegraphs(ctx); + if (G.devMode) drawDebugOverlays(ctx); ctx.restore(); if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0); @@ -340,7 +344,7 @@ function draw() { else if (G.mode === 'pause') { drawPause(ctx); if (G.pauseHelp) drawPauseHelp(ctx); } else if (G.mode === 'gameover') drawGameOver(ctx); else if (G.mode === 'debug') drawDebug(ctx); - if (G.devMode) { drawDebugOverlays(ctx); if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); } + if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); } } function drawMenu(ctx) { diff --git a/js/player.js b/js/player.js index ffeed78..0848c3a 100644 --- a/js/player.js +++ b/js/player.js @@ -95,7 +95,7 @@ function updatePlayer(dt) { p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r); // aim - p.aim = angleTo(p.x, p.y, Input.mx, Input.my); + p.aim = angleTo(p.x, p.y, mxW(), myW()); // The waist and legs turn smoothly with travel while the weapon torso aims freely. // Once planted, the lower body turns back under the independently aimed torso. if (mx || my) { diff --git a/js/rooms.js b/js/rooms.js index 8e37fe9..7032351 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -3,11 +3,15 @@ const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; const OPP = { n: 's', s: 'n', e: 'w', w: 'e' }; const ROOM_SHAPES = { - hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, - wide_hall: { x0: 48, y0: 208, x1: 912, y1: 432 }, - tall_hall: { x0: 368, y0: 48, x1: 592, y1: 592 }, - chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, - pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, + hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms + wide_hall: { x0: 48, y0: 176, x1: 912, y1: 464 }, // h 224 -> 288 ("a little wider"; cy stays 320, min dim 288 < 300 keeps censer/broodsac excluded) + tall_hall: { x0: 336, y0: 48, x1: 624, y1: 592 }, // w 224 -> 288 (cx stays 480) + chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, // unchanged — item rooms + pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, // unchanged + grand_hall:{ x0: 48, y0: 48, x1: 1680, y1: 592 }, // new, ~1.7× wide — camera scrolls horizontally (w 1632 > 960) + deep_hall: { x0: 480, y0: 48, x1: 912, y1: 1136 }, // new, ~1.85× tall — camera scrolls vertically (h 1088 > 640) + meat_hall: { x0: 48, y0: 48, x1: 1680, y1: 1136 }, // new, 1.7× × 1.85× — camera scrolls both axes + odd_hall: { x0: 160, y0: 128, x1: 800, y1: 512 }, // new, fits screen but off-center — visually "odd" with zero camera }; const ROOM_THEMES = { abattoir: { floor: ['tile_floor1', 'tile_floor1', 'tile_floor4', 'tile_floor6'], wall: ['tile_wall', 'tile_wall2'] }, @@ -49,13 +53,14 @@ function genFloor(floor) { const d = Math.abs(r.gx) + Math.abs(r.gy); if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; } } - const combatShapes = ['hall', 'hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall']; + const smallShapes = ['hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall', 'odd_hall']; + const bigShapes = ['grand_hall', 'deep_hall', 'meat_hall']; const themes = Object.keys(ROOM_THEMES); for (const r of Object.values(G.rooms)) { r.theme = choice(themes); if (r.type === 'start' || r.type === 'boss') r.shape = 'hall'; else if (r.type === 'item') r.shape = 'chamber'; - else r.shape = choice(combatShapes); + else r.shape = (G.floor >= 2 && chance(0.30)) ? choice(bigShapes) : choice(smallShapes); for (const [dir, [dx, dy]] of Object.entries(DIRS)) { r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)]; } @@ -143,6 +148,20 @@ function enterRoom(gx, gy) { } } +// Isaac-style: the view centers on the player and is clamped so it never leaves +// the room. Rooms that fit the screen keep cam exactly at the room center — +// which is (0,0) for every current shape, so existing rendering is byte-identical. +function updateCamera() { + const a = G.arena, p = G.player; + if (!a || !p) return; + G.cam = G.cam || { x: 0, y: 0 }; + G.cam.x = a.w <= W ? a.cx - W / 2 : clamp(p.x - W / 2, a.x0, a.x1 - W); + G.cam.y = a.h <= H ? a.cy - H / 2 : clamp(p.y - H / 2, a.y0, a.y1 - H); +} +// screen-space mouse -> world coords (safe pre-player: cam exists from state.js) +function mxW() { return Input.mx + G.cam.x; } +function myW() { return Input.my + G.cam.y; } + function separateEntryWave() { const p = G.player; const a = G.arena; @@ -338,11 +357,11 @@ const FLOOR_TILE_POOL = [ ]; const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4']; -function drawFloorTiles(target, r) { +function drawFloorTiles(target, r, extW, extH) { const a = roomBounds(r); const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir; target.fillStyle = '#050204'; - target.fillRect(0, 0, W, H); + target.fillRect(0, 0, extW === undefined ? W : extW, extH === undefined ? H : extH); target.save(); target.beginPath(); target.rect(a.x0, a.y0, a.w, a.h); target.clip(); const tx0 = Math.floor(a.x0 / TILE), tx1 = Math.ceil(a.x1 / TILE); @@ -364,11 +383,12 @@ function drawFloor(ctx, r) { if (!canCache) { drawFloorTiles(ctx, r); return; } const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme; if (!G.roomLayer || G.roomLayerKey !== key) { + const a = roomBounds(r); const layer = document.createElement('canvas'); - layer.width = W; layer.height = H; + layer.width = Math.max(W, a.x1); layer.height = Math.max(H, a.y1); const lctx = layer.getContext('2d'); lctx.imageSmoothingEnabled = false; - drawFloorTiles(lctx, r); + drawFloorTiles(lctx, r, layer.width, layer.height); G.roomLayer = layer; G.roomLayerKey = key; } ctx.drawImage(G.roomLayer, 0, 0); diff --git a/js/state.js b/js/state.js index 9d26fa4..ee43ed5 100644 --- a/js/state.js +++ b/js/state.js @@ -53,6 +53,7 @@ const G = { roomLayerKey: '', atmosphereLayer: null, arena: { x0: WALL, y0: WALL, x1: W - WALL, y1: H - WALL, cx: W / 2, cy: H / 2, w: W - WALL * 2, h: H - WALL * 2 }, + cam: { x: 0, y: 0 }, // world coords of the viewport's top-left; 0,0 for every fit-to-screen room }; const PERK_POWER_WEIGHT = 0.5; diff --git a/js/weapons.js b/js/weapons.js index 2c32daf..eaf7d66 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -100,11 +100,11 @@ function fireWeapon(p, w) { break; } case 'lob': case 'lob_trap': case 'lob_swarm': { - const primaryD = Math.max(dist(bx, by, Input.mx, Input.my), 1); + const primaryD = Math.max(dist(bx, by, mxW(), myW()), 1); for (let i = 0; i < volley.length; i++) { const a = volley[i]; - const tx = i === 0 ? Input.mx : p.x + Math.cos(a) * primaryD; - const ty = i === 0 ? Input.my : p.y + Math.sin(a) * primaryD; + const tx = i === 0 ? mxW() : p.x + Math.cos(a) * primaryD; + const ty = i === 0 ? myW() : p.y + Math.sin(a) * primaryD; const d = Math.max(dist(bx, by, tx, ty), 1); const flight = clamp(d / (520 * spdMul), 0.12, 0.8); mk(a, { diff --git a/test/smoke.js b/test/smoke.js index 9404c68..b813a74 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -61,7 +61,7 @@ for (const f of files) { } // const/let top-level bindings live in the context's lexical scope, not on the // global object — expose the ones the harness pokes at directly. -vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox); +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds })', sandbox); let simTime = 0; const ctx = sandbox; @@ -279,6 +279,64 @@ console.log('== variable room shapes and themes =='); ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py; } +console.log('== big rooms + camera =='); +{ + // fit rooms keep camera pinned at (0,0) — current look is unchanged + const fit = ctx.makeRoom(0, 0, 'combat'); fit.shape = 'hall'; + ctx.setArenaForRoom(fit); + ctx.G.player.x = 100; ctx.G.player.y = 320; + ctx.updateCamera(); + check('fit rooms keep camera at origin', ctx.G.cam.x === 0 && ctx.G.cam.y === 0); + + // wide room: camera follows the player, clamped to the room + const wide = ctx.makeRoom(0, 0, 'combat'); wide.shape = 'grand_hall'; + ctx.setArenaForRoom(wide); + const wa = ctx.G.arena; + ctx.G.player.x = 100; ctx.G.player.y = 320; ctx.updateCamera(); + check('camera clamps at left arena edge', ctx.G.cam.x === wa.x0); + ctx.G.player.x = wa.x1 - 200; ctx.G.player.y = 320; ctx.updateCamera(); + check('camera clamps at right arena edge', ctx.G.cam.x === wa.x1 - ctx.W); + ctx.G.player.x = wa.cx; ctx.G.player.y = 320; ctx.updateCamera(); + check('camera centers on the player mid-room', ctx.G.cam.x === wa.cx - ctx.W / 2 && ctx.G.cam.y === 0); + + // world-mouse conversion + ctx.G.cam.x = wa.cx - ctx.W / 2; ctx.Input.mx = 120; ctx.Input.my = 240; + check('screen mouse maps to world coords', ctx.mxW() === 120 + ctx.G.cam.x && ctx.myW() === 240); + + // end-to-end: live in a big room — update+draw path, floor direct path, aim + ctx.startRun(); + const big = ctx.G.rooms['0,0']; + big.shape = 'meat_hall'; + ctx.G.cur = big; ctx.setArenaForRoom(big); + ctx.G.player.x = ctx.G.arena.x0 + 80; ctx.G.player.y = ctx.G.arena.y0 + 80; + ctx.G.enemies.length = 0; + ctx.Input.mx = 300; ctx.Input.my = 200; ctx.Input.mdown = true; + step(3, 16); // runs update() (firing, weapon lob path) + draw() (direct floor, camera) + check('big room runs without error and aims at world mouse', + Math.abs(ctx.G.player.aim - ctx.angleTo(ctx.G.player.x, ctx.G.player.y, ctx.Input.mx + ctx.G.cam.x, ctx.Input.my + ctx.G.cam.y)) < 1e-9); + ctx.Input.mdown = false; + ctx.startRun(); step(3, 16); +} + +// floor >= 2 mixes big/odd rooms into combat shapes; doors stay reciprocal +{ + ctx.G.floor = 6; + let sawBig = false, reciprocal = true; + for (let trial = 0; trial < 40 && !sawBig; trial++) { + ctx.genFloor(6); + for (const r of Object.values(ctx.G.rooms)) { + if (r.type === 'combat' && ['grand_hall', 'deep_hall', 'meat_hall', 'odd_hall'].includes(r.shape)) sawBig = true; + if (r.doors.n && !ctx.G.rooms[(r.gx) + ',' + (r.gy - 1)]) reciprocal = false; + if (r.doors.s && !ctx.G.rooms[(r.gx) + ',' + (r.gy + 1)]) reciprocal = false; + if (r.doors.e && !ctx.G.rooms[(r.gx + 1) + ',' + (r.gy)]) reciprocal = false; + if (r.doors.w && !ctx.G.rooms[(r.gx - 1) + ',' + (r.gy)]) reciprocal = false; + } + } + check('floor 6 mixes in big and odd rooms', sawBig); + check('big-room maps keep reciprocal doors', reciprocal); + ctx.startRun(); step(3, 16); +} + console.log('== XP / perks =='); ctx.gainXP(100); step(5, 16); From 9c6fc194cac5f947baf342e8bfea3767e028824d Mon Sep 17 00:00:00 2001 From: Quintine Date: Tue, 11 Aug 2026 08:51:48 +1000 Subject: [PATCH 2/2] release: bump game and memory versions to 0.6.0 (big rooms) --- docs/development.md | 6 +++--- index.html | 44 ++++++++++++++++++++++---------------------- js/sfxbank.js | 2 +- js/sprites.js | 2 +- package.json | 2 +- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/development.md b/docs/development.md index c6de96e..26a934b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,7 +4,7 @@ This guide consolidates the durable project knowledge used to develop, test, package, and maintain MeatSlicer. When this guide and the implementation disagree, the implementation and configuration files are the source of truth. -**Memory version: 0.5.2** +**Memory version: 0.6.0** ## Architecture @@ -201,8 +201,8 @@ failures, and an SFX buffer count matching `SfxBank.FILES` and the files on disk; the exact count can change as audio is added. The Windows build is not code-signed, so downloaded copies may trigger a -SmartScreen warning. Code signing, installers, automatic updates, and -macOS/Linux packaging remain future work. +SmartScreen warning. Code signing, installers, and automatic updates remain +future work. ## Visual asset pipeline diff --git a/index.html b/index.html index 2e8a8c0..546184a 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/sfxbank.js b/js/sfxbank.js index 2ad8586..6136383 100644 --- a/js/sfxbank.js +++ b/js/sfxbank.js @@ -111,7 +111,7 @@ const SfxBank = { if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; const file = this.FILES[name]; if (!file || !this.ctx || typeof fetch !== 'function') return; - const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=51') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=52') .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(bytes => this.ctx.decodeAudioData(bytes)) .then(buffer => this.buffers.set(name, this.trim(buffer))) diff --git a/js/sprites.js b/js/sprites.js index a5d87e3..2a5ddc0 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -86,7 +86,7 @@ const Sprites = { const img = new Image(); img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; }; img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; }; - img.src = 'assets/' + name + '.png?v=51'; + img.src = 'assets/' + name + '.png?v=52'; } }, diff --git a/package.json b/package.json index 188b9cb..63c17c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.5.2", + "version": "0.6.0", "private": true, "description": "A Butcher's Descent - clear, harvest, descend.", "main": "desktop/main.js",