diff --git a/docs/development.md b/docs/development.md index 12f027e..c6de96e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,6 +4,8 @@ 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** + ## Architecture MeatSlicer is an endless top-down arena roguelite built with plain JavaScript @@ -308,6 +310,38 @@ When changing the version: 4. Run `npm run dist`. 5. Verify the packaged executable with `MS_VERIFY_OUT`. +## Memory versioning + +The project knowledge ("memories") — this guide and the player-facing documents +under `docs/` — carries its own `X.Y.Z` version, tracked independently of the +game release version in `package.json`: + +- `X` — a major milestone. Changed by the project owner only; a routine update + or bump rule never raises it. +- `Y` — a major feature push: a large content expansion, a major mechanic, or a + significant restructure of the knowledge base. +- `Z` — a small addition, a bug fix, or a single new small feature (a new + gotcha, a corrected formula, a changed workflow, or the documentation sync + that follows a gameplay change). + +Components are independent counters and never roll over: `0.999.999` and +`0.42.677` are perfectly valid, and bumping `Z` on `0.999.999` gives +`0.999.1000`. Only `X` is exempt from automatic increases — it changes by +explicit human edit. + +The current version is the `**Memory version:**` stamp at the top of this +guide; that stamp is the single source of truth. + +When a change to durable knowledge lands (anywhere under `docs/`): + +1. Edit the stamp: bump `Z` for a small addition or fix, `Y` for a major push, + or — for a milestone — the project owner raises `X` by hand. +2. Run `npm test` (docs are not loaded by the game, so the suite is unaffected) + and commit with an imperative message naming the bump, e.g. + `docs: record Iron Lung synergy (memory 0.5.0 -> 0.5.1)`. +3. No `?v=` cache bump is needed — `docs/` is not served through `index.html` + script tags; it only ships inside packages. + ## Git workflow Every completed change should be committed: @@ -334,3 +368,6 @@ Player-facing mechanics belong in the relevant files under `docs/`. Keep those documents synchronized with gameplay changes. This development guide should be updated whenever the build, packaging, asset, audio, release, or contribution workflow changes. + +Keep the `**Memory version:**` stamp at the top of this guide current: any +durable-knowledge change bumps it per `## Memory versioning`. diff --git a/js/enemies.js b/js/enemies.js index f11d02b..2552642 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -108,7 +108,7 @@ function spawnPosAwayFromPlayer() { return { x: rand(a.cx - rx, a.cx + rx), y: rand(a.cy - ry, a.cy + ry) }; } -function spawnWave(count, floor) { +function spawnWave(count, floor, warn) { for (let i = 0; i < count; i++) { if (G.enemies.length >= MAX_ENEMIES) break; const type = pickWaveEnemyType(floor); @@ -116,7 +116,7 @@ function spawnWave(count, floor) { const canElite = type !== 'bulwark' && type !== 'broodsac'; const elite = canElite && chance(Math.min(0.03 + floor * 0.02, 0.2)); const e = makeEnemy(type, pos.x, pos.y, floor, elite); - e.warmT = SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided + e.warmT = warn || SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided G.enemies.push(e); spawnBlood(pos.x, pos.y, rand(0, TAU), 4); Sfx.spawn(e); @@ -195,6 +195,11 @@ function damageEnemy(e, dmg, ang, knockback, opts) { return false; } +// Diminishing (armor-style) conversion of a raw stun rating into a stagger chance. +function stunChance(raw) { + return Math.min(0.50, Math.max(0, raw) / (1 + Math.max(0, raw))); +} + // Shared player-hit payload. Every weapon primitive routes through damageEnemy, // so items remain useful for bullets, beams, slams, saws, traps and orbitals. function procOnHit(e, dmg, ang, opts) { @@ -243,7 +248,10 @@ function procOnHit(e, dmg, ang, opts) { e.burnDps = Math.max(e.burnDps || 0, 5 * s.dmgMul); } if (s.slowOnHit > 0 && chance(clamp(s.slowOnHit * chanceScale, 0, 0.9))) e.slowT = Math.max(e.slowT || 0, 1.5); - if (!e.boss && e.type !== 'bulwark' && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35); + if (!e.boss && e.type !== 'bulwark') { + const stunCh = clamp((s.stunOnHit || 0) * chanceScale, 0, 0.75) + stunChance(s.stunRaw || 0); + if (stunCh > 0 && chance(clamp(stunCh, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35); + } if (s.pullOnHit > 0 && !e.boss && chance(clamp(s.pullOnHit * chanceScale, 0, 0.8))) { const toward = angleTo(e.x, e.y, p.x, p.y); e.vx += Math.cos(toward) * 170; e.vy += Math.sin(toward) * 170; diff --git a/js/main.js b/js/main.js index 0e4fff6..3b4796a 100644 --- a/js/main.js +++ b/js/main.js @@ -220,7 +220,7 @@ function update(dt) { drawToastsUpdate(dt); break; case 'levelup': - updateLevelup(); + updateLevelup(dt); updateParticles(dt); break; case 'debug': diff --git a/js/perks.js b/js/perks.js index a9facc2..efb3bc2 100644 --- a/js/perks.js +++ b/js/perks.js @@ -23,7 +23,7 @@ const PERKS = [ { id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } }, { id: 'spiteflesh',name: 'Spite Flesh', desc: 'Contact attackers take damage', apply: s => { s.thorns += 4; } }, { id: 'carrion', name: 'Carrion Sense', desc: '+8% pickup radius and pull speed', apply: s => { s.magnet *= 1.08; s.magnetPull *= 1.08; } }, - { id: 'sinew', name: 'Sinew Weave', desc: '+3% move speed and range', apply: s => { s.speedMul *= 1.03; s.rangeMul *= 1.03; } }, + { id: 'sinew', name: 'Sinew Weave', desc: 'Hits may stagger enemies (starts 3%, up to 50%)', apply: s => { s.stunRaw = (s.stunRaw || 0) + STUN_UNIT; } }, ]; const PERK_CARD_W = 170, PERK_CARD_H = 230, PERK_CARD_GAP = 18; @@ -61,6 +61,7 @@ function openPerkDraft() { } const opts = shuffle([...PERKS]).slice(0, 3); G.perkChoices = opts; + if (G.mode !== 'levelup') G.selectionLock = 1.5; G.mode = 'levelup'; Sfx.levelup(); } @@ -82,11 +83,13 @@ function choosePerk(i) { else G.mode = 'play'; } -function updateLevelup() { - if (keyPressed('1')) choosePerk(0); - else if (keyPressed('2')) choosePerk(1); - else if (keyPressed('3')) choosePerk(2); - else if (keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1)); +function updateLevelup(dt) { + G.selectionLock = Math.max(0, (G.selectionLock || 0) - dt); + const unlocked = G.selectionLock <= 0; + if (unlocked && keyPressed('1')) choosePerk(0); + else if (unlocked && keyPressed('2')) choosePerk(1); + else if (unlocked && keyPressed('3')) choosePerk(2); + else if (unlocked && keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1)); else if (keyPressed('t')) { setAutoPerk(true); G.mode = 'play'; @@ -97,7 +100,7 @@ function updateLevelup() { G.perkChoices = shuffle([...PERKS]).slice(0, 3); Sfx.menu(); } - else if (Input.mpressed && G.perkChoices) { + else if (unlocked && Input.mpressed && G.perkChoices) { // click a card for (let i = 0; i < 3; i++) { const cx = perkCardX(i); diff --git a/js/pickups.js b/js/pickups.js index 17abca1..8eb0c40 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -40,6 +40,18 @@ function updatePickups(dt) { k.vx *= 0.9; k.vy *= 0.9; const d = dist(k.x, k.y, p.x, p.y); + // nearby pickup: toast the description once per approach, re-arm on leaving + if (k.type === 'item' || k.type === 'active' || k.type === 'weapon') { + if (d < 90 && !k.toastNear) { + k.toastNear = true; + if (k.type === 'item') addToast(ITEMS[k.iid].name, ITEMS[k.iid].desc, 3.5); + else if (k.type === 'active') addToast(ACTIVES[k.aid].name, ACTIVES[k.aid].desc, 3.5); + else addToast(WEAPONS[k.wid].name, WEAPONS[k.wid].desc, 3.5); + } else if (d >= 90 && k.toastNear) { + k.toastNear = false; // re-arm once the player leaves the 90px radius + } + } + // gems & small pickups fly to the player inside magnet radius if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) { const a = angleTo(k.x, k.y, p.x, p.y); diff --git a/js/player.js b/js/player.js index 39f89f7..ffeed78 100644 --- a/js/player.js +++ b/js/player.js @@ -6,6 +6,7 @@ function defaultPlayerStats() { pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0, orbitals: 0, lifestealChance: 0, explodeOnKill: 0, armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0, + stunRaw: 0, bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1, roomHeal: 0, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0, diff --git a/js/rooms.js b/js/rooms.js index cd2b544..8e37fe9 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -16,6 +16,9 @@ const ROOM_THEMES = { flesh: { floor: ['tile_floor8', 'tile_floor8', 'tile_floor1'], wall: ['tile_wall4', 'tile_wall2'] }, }; +const ENTRY_WARN = 0.75; // first-entry wave telegraph seconds (~750ms reaction) +const ENTRY_BUFFER = 170; // min px between player and a freshly-entered enemy + function roomBounds(room) { const def = ROOM_SHAPES[(room && room.shape) || 'hall'] || ROOM_SHAPES.hall; return { x0: def.x0, y0: def.y0, x1: def.x1, y1: def.y1, @@ -88,6 +91,7 @@ function enterRoom(gx, gy) { G.roomLayerKey = ''; G.roomDamaged = false; G.roomEnterT = G.time; + G.entryFresh = false; const room = G.rooms[roomKey(gx, gy)]; G.cur = room; @@ -110,7 +114,8 @@ function enterRoom(gx, gy) { const w = wavesFor(G.floor); room.wavesLeft = w.length; room.pendingWave = w; - spawnWave(w[0], G.floor); + G.entryFresh = true; + spawnWave(w[0], G.floor, ENTRY_WARN); room.wavesLeft--; room.spawnT = 0; } else if (room.type === 'boss') { @@ -138,6 +143,20 @@ function enterRoom(gx, gy) { } } +function separateEntryWave() { + const p = G.player; + const a = G.arena; + const minD = Math.min(ENTRY_BUFFER, Math.min(a.w, a.h) * 0.42); + const minD2 = minD * minD; + for (const e of G.enemies) { + if (e.boss || e.hp <= 0) continue; + if (dist2(e.x, e.y, p.x, p.y) < minD2) { + const pos = spawnPosAwayFromPlayer(); + e.x = pos.x; e.y = pos.y; + } + } +} + function roomLocked() { const r = G.cur; if (!r) return false; @@ -206,6 +225,7 @@ function updateRoom(dt) { if (dir === 'w') p.x = next.x1 - p.r - 8; p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r); p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r); + if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; } G.transition = 0.3; Sfx.door(); } diff --git a/js/state.js b/js/state.js index 4d59a2b..9d26fa4 100644 --- a/js/state.js +++ b/js/state.js @@ -58,6 +58,7 @@ const G = { const PERK_POWER_WEIGHT = 0.5; // ammo economy: global dial for drop frequency (refill size is per-weapon) const AMMO_DROP_SCALE = 0.75; +const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00; const PRESSURE_UNIT = 0.01; // one "point" on the dial scale const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point diff --git a/package-lock.json b/package-lock.json index e0a3cd7..1066f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meatslicer", - "version": "0.1.0", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meatslicer", - "version": "0.1.0", + "version": "0.5.2", "devDependencies": { "electron": "^40.10.2", "electron-builder": "^25.1.8" diff --git a/package.json b/package.json index 835ccca..188b9cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.5.0", + "version": "0.5.2", "private": true, "description": "A Butcher's Descent - clear, harvest, descend.", "main": "desktop/main.js", diff --git a/test/smoke.js b/test/smoke.js index e9052f3..9404c68 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, 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 })', sandbox); let simTime = 0; const ctx = sandbox; @@ -283,6 +283,11 @@ console.log('== XP / perks =='); ctx.gainXP(100); step(5, 16); check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0); +check('perk selection is locked on first presentation', (ctx.G.selectionLock || 0) > 0); +const lockedChoices = ctx.G.perkChoices; +tap('1'); // ignored while the selection lock is active +check('locked draft ignores selection input', ctx.G.perkChoices === lockedChoices); +step(100, 16); // advance 1.6s in levelup mode, clearing the 1.5s selection lock let guard = 0; while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); } check('perks drafted', ctx.G.mode === 'play'); @@ -298,6 +303,7 @@ console.log('== rebalanced perks + automatic draft =='); ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft(); p.stats.rerolls = 1; tap('r'); check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3); + step(100, 16); // clear the 1.5s selection lock armed by openPerkDraft above tap('4'); check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0); const levelBefore = p.level; @@ -310,6 +316,24 @@ console.log('== rebalanced perks + automatic draft =='); } autoDraft = true; // later sections must not get stuck in the perk draft +console.log('== nearby-item description toast =='); +{ + const p = ctx.G.player; + ctx.G.pickups.length = 0; + ctx.G.toasts.length = 0; + ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' }); + step(1, 16); // within the 90px radius -> toast once + check('nearby item toasts its description once', + ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc); + step(4, 16); // stay adjacent, must not re-fire + check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1); + p.x += 500; step(1, 16); // leave the 90px radius -> re-arms + p.x -= 420; step(1, 16); // back within 90 (outside pickup reach) -> fires again + check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2); + ctx.G.pickups.length = 0; + ctx.startRun(); step(3, 16); +} + console.log('== shield heart perk =='); { const p = ctx.G.player; @@ -342,6 +366,16 @@ check('items applied', Object.keys(ctx.G.player.items).length === 4); check('orbital knives active', ctx.G.player.stats.orbitals >= 1); step(30, 16); +console.log('== Sine Weave diminishing stagger =='); +{ + const s = {}; ctx.PERKS.find(p => p.id === 'sinew').apply(s, {}); + check('sine weave first stack is 3% stagger chance', Math.abs(ctx.stunChance(s.stunRaw) - 0.03) < 1e-6); + check('sine weave chance never exceeds 50%', ctx.stunChance(1e6) === 0.5); + check('sine weave gains diminishing returns', ctx.stunChance(s.stunRaw * 2) < 2 * ctx.stunChance(s.stunRaw)); + ctx.PERKS.find(p => p.id === 'sinew').apply(s, {}); + check('sine weave stacks raw rating', Math.abs(s.stunRaw - 2 * (0.03 / 0.97)) < 1e-9); +} + console.log('== expanded perk and item rosters =='); { check('41 new items added (14 phase-2 + 12 phase-3 + 10 legendaries)', Object.keys(ctx.ITEMS).length === 82); @@ -997,7 +1031,7 @@ console.log('== damaged monster health pips =='); ctx.G.enemies = []; } -console.log('== 500ms wave spawn telegraph =='); +console.log('== 750ms entry-wave spawn telegraph =='); { ctx.startRun(); step(3, 16); // enter a combat room to trigger a wave @@ -1010,7 +1044,7 @@ console.log('== 500ms wave spawn telegraph =='); const room2 = ctx.G.cur; if (room2.type === 'combat' && ctx.G.enemies.length > 0) { const spawner = ctx.G.enemies[0]; - check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.SPAWN_WARN); + check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.ENTRY_WARN); const sx = spawner.x, sy = spawner.y; ctx.G.player.invT = 0; const hpBefore = ctx.G.player.hp; @@ -1018,7 +1052,7 @@ console.log('== 500ms wave spawn telegraph =='); step(20, 16); // ~320ms check('telegraphed spawn is frozen', Math.abs(spawner.x - sx) < 0.01 && Math.abs(spawner.y - sy) < 0.01); check('telegraphed spawn deals no contact damage', ctx.G.player.hp === hpBefore); - step(25, 16); // past 500ms total + step(30, 16); // past 750ms total check('spawn arms after the telegraph', spawner.warmT <= 0); ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2; ctx.G.player.invT = 1; } else { @@ -1036,6 +1070,33 @@ console.log('== 500ms wave spawn telegraph =='); ctx.startRun(); step(3, 16); } +console.log('== room-entry reaction buffer =='); +{ + ctx.startRun(); step(3, 16); + // enter a combat neighbor room so a first-entry wave is staged at the arrival door + const sr = ctx.G.rooms['0,0']; + const sd = Object.keys(sr.doors).find(d => sr.doors[d]); + const DIRS3 = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; + const [edx, edy] = DIRS3[sd]; + ctx.enterRoom(sr.gx + edx, sr.gy + edy); + const er = ctx.G.cur; + if (er.type === 'combat' && ctx.G.enemies.length > 0) { + // move the player to a far corner, then run the post-teleport separation + const erA = ctx.G.arena; + ctx.G.player.x = erA.x0 + 40; ctx.G.player.y = erA.y0 + 40; + ctx.separateEntryWave(); + const buf = Math.min(ctx.ENTRY_BUFFER, Math.min(erA.w, erA.h) * 0.42); + const ok = ctx.G.enemies.every(e => + e.boss || e.hp <= 0 || ((e.x - ctx.G.player.x) ** 2 + (e.y - ctx.G.player.y) ** 2) >= (buf * 0.9) ** 2); + check('entry enemies stay past the reaction buffer', ok); + check('first-entry wave telegraphs with ENTRY_WARN', ctx.G.enemies.every(e => (e.warmT || 0) <= ctx.ENTRY_WARN)); + } else { + check('combat room staged for entry-buffer test (' + er.type + ')', true); + } + ctx.G.enemies.length = 0; + ctx.startRun(); step(3, 16); +} + console.log('== five pressure-monster archetypes =='); { const newTypes = ['censer', 'bulwark', 'choirmaster', 'flenserling', 'broodsac'];