feat: room-entry reaction buffer, 1.5s perk-selection lock, nearby-item toasts, Sine Weave stagger perk
This commit is contained in:
1 parent
97ab316304
commit
5d5eb3d265
8 files changed
+122
-16
No files matched your search
+11
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -220,7 +220,7 @@ function update(dt) {
|
||||
drawToastsUpdate(dt);
|
||||
break;
|
||||
case 'levelup':
|
||||
updateLevelup();
|
||||
updateLevelup(dt);
|
||||
updateParticles(dt);
|
||||
break;
|
||||
case 'debug':
|
||||
|
||||
+10
-7
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+21
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
-4
@@ -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'];
|
||||
|
||||
Reference in new issue
Block a user