Merge pull request #1 from Quintine/feature/memory-versioning
Feature/memory versioning
This commit is contained in:
11 files changed
+162
-19
No files matched your search
@@ -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`.
|
||||
+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
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+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