From 5d5eb3d26555f9f3dd0e6b1c0a7727b6991cf1d5 Mon Sep 17 00:00:00 2001 From: Quintine Date: Mon, 10 Aug 2026 18:31:36 +1000 Subject: [PATCH] feat: room-entry reaction buffer, 1.5s perk-selection lock, nearby-item toasts, Sine Weave stagger perk --- js/enemies.js | 14 ++++++++--- js/main.js | 2 +- js/perks.js | 17 +++++++------ js/pickups.js | 12 +++++++++ js/player.js | 1 + js/rooms.js | 22 +++++++++++++++- js/state.js | 1 + test/smoke.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 122 insertions(+), 16 deletions(-) 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/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'];