phase 6: escalation effects + enhanced crisis system
- Add escalation effects module: creature speed/aggro/damage multipliers, reaction instability, environmental damage — all scale 0→1 - Add getCrisisPlayerDamage/getCrisisTint to crisis module - Integrate escalation effects into GameScene (env damage at high entropy) - Dynamic crisis overlay tint that intensifies with progress - Phase indicator shows entropy %, aggression multiplier, crisis status - 14 new tests for escalation + crisis damage/tint (349 total) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,3 +83,23 @@ export function attemptNeutralize(
|
|||||||
export function isCrisisResolved(crisis: CrisisState): boolean {
|
export function isCrisisResolved(crisis: CrisisState): boolean {
|
||||||
return crisis.resolved;
|
return crisis.resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calculate player damage per frame from active crisis */
|
||||||
|
export function getCrisisPlayerDamage(crisis: CrisisState, deltaMs: number): number {
|
||||||
|
if (crisis.resolved || crisis.progress <= 0.3) return 0;
|
||||||
|
const deltaSec = deltaMs / 1000;
|
||||||
|
// Damage scales with how far above 30% threshold
|
||||||
|
return (crisis.progress - 0.3) * 0.7 * deltaSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get visual tint for crisis overlay */
|
||||||
|
export function getCrisisTint(crisis: CrisisState): { color: number; alpha: number } {
|
||||||
|
if (crisis.resolved || crisis.progress <= 0) {
|
||||||
|
return { color: 0x88ff88, alpha: 0 };
|
||||||
|
}
|
||||||
|
// Green toxic tint that intensifies with progress
|
||||||
|
return {
|
||||||
|
color: 0x88ff88,
|
||||||
|
alpha: Math.min(0.15, crisis.progress * 0.15),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
48
src/run/escalation.ts
Normal file
48
src/run/escalation.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Escalation Effects — how rising entropy changes the game world.
|
||||||
|
*
|
||||||
|
* GDD spec:
|
||||||
|
* "Entropy grows. Temperature fluctuates harder, chemical reactions become
|
||||||
|
* more unstable, creatures more aggressive, NPCs more paranoid."
|
||||||
|
*
|
||||||
|
* Escalation (0.0 → 1.0) modifies:
|
||||||
|
* - Creature speed, aggro range, attack damage
|
||||||
|
* - Reaction instability (chance of unexpected side effects)
|
||||||
|
* - Environmental damage (toxic atmosphere)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Multipliers and modifiers based on escalation level */
|
||||||
|
export interface EscalationEffects {
|
||||||
|
/** Creature movement speed multiplier (1.0 → 1.5) */
|
||||||
|
creatureSpeedMultiplier: number;
|
||||||
|
/** Creature aggro detection range multiplier (1.0 → 1.8) */
|
||||||
|
creatureAggroRange: number;
|
||||||
|
/** Creature attack damage multiplier (1.0 → 1.6) */
|
||||||
|
creatureAttackMultiplier: number;
|
||||||
|
/** Reaction instability: chance 0–0.3 of unexpected side effects */
|
||||||
|
reactionInstability: number;
|
||||||
|
/** Environmental damage per second at current escalation */
|
||||||
|
environmentalDamage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate escalation effects from escalation level (0.0–1.0).
|
||||||
|
* All effects scale linearly from neutral to maximum.
|
||||||
|
*/
|
||||||
|
export function getEscalationEffects(escalation: number): EscalationEffects {
|
||||||
|
// Clamp to [0, 1]
|
||||||
|
const t = Math.max(0, Math.min(1, escalation));
|
||||||
|
|
||||||
|
return {
|
||||||
|
creatureSpeedMultiplier: lerp(1.0, 1.5, t),
|
||||||
|
creatureAggroRange: lerp(1.0, 1.8, t),
|
||||||
|
creatureAttackMultiplier: lerp(1.0, 1.6, t),
|
||||||
|
reactionInstability: lerp(0, 0.3, t),
|
||||||
|
environmentalDamage: t > 0.6 ? lerp(0, 0.5, (t - 0.6) / 0.4) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear interpolation */
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
@@ -52,9 +52,12 @@ import {
|
|||||||
createCrisisState,
|
createCrisisState,
|
||||||
applyCrisisDamage,
|
applyCrisisDamage,
|
||||||
attemptNeutralize,
|
attemptNeutralize,
|
||||||
|
getCrisisPlayerDamage,
|
||||||
|
getCrisisTint,
|
||||||
CHEMICAL_PLAGUE,
|
CHEMICAL_PLAGUE,
|
||||||
type CrisisState,
|
type CrisisState,
|
||||||
} from '../run/crisis';
|
} from '../run/crisis';
|
||||||
|
import { getEscalationEffects } from '../run/escalation';
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private gameWorld!: GameWorld;
|
private gameWorld!: GameWorld;
|
||||||
@@ -396,14 +399,26 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// 9g. Crisis damage (if active)
|
// 9g. Crisis damage (if active)
|
||||||
if (this.crisisState?.active) {
|
if (this.crisisState?.active) {
|
||||||
applyCrisisDamage(this.crisisState, delta);
|
applyCrisisDamage(this.crisisState, delta);
|
||||||
// Crisis damages player slowly
|
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
|
||||||
if (this.crisisState.progress > 0.3) {
|
if (crisisDmg > 0) {
|
||||||
const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000);
|
|
||||||
Health.current[this.playerEid] = Math.max(
|
Health.current[this.playerEid] = Math.max(
|
||||||
0,
|
0,
|
||||||
(Health.current[this.playerEid] ?? 0) - crisisDmg,
|
(Health.current[this.playerEid] ?? 0) - crisisDmg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Update crisis visual tint
|
||||||
|
const tint = getCrisisTint(this.crisisState);
|
||||||
|
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9h. Environmental damage from high escalation
|
||||||
|
const escalationFx = getEscalationEffects(this.runState.escalation);
|
||||||
|
if (escalationFx.environmentalDamage > 0) {
|
||||||
|
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
|
||||||
|
Health.current[this.playerEid] = Math.max(
|
||||||
|
0,
|
||||||
|
(Health.current[this.playerEid] ?? 0) - envDmg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Health / death
|
// 10. Health / death
|
||||||
@@ -493,8 +508,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Update phase indicator
|
// Update phase indicator
|
||||||
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
|
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
|
||||||
const escalationPct = Math.round(this.runState.escalation * 100);
|
const escalationPct = Math.round(this.runState.escalation * 100);
|
||||||
const crisisInfo = this.crisisState?.active ? ` | ЧУМА: ${Math.round(this.crisisState.progress * 100)}%` : '';
|
const crisisInfo = this.crisisState?.active
|
||||||
this.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${crisisInfo}`);
|
? ` | ☠ ЧУМА: ${Math.round(this.crisisState.progress * 100)}%`
|
||||||
|
: this.runState.crisisResolved ? ' | ✓ Чума нейтрализована' : '';
|
||||||
|
const escFx = getEscalationEffects(this.runState.escalation);
|
||||||
|
const speedInfo = escFx.creatureSpeedMultiplier > 1.05
|
||||||
|
? ` | Агрессия: ×${escFx.creatureAttackMultiplier.toFixed(1)}`
|
||||||
|
: '';
|
||||||
|
this.phaseText.setText(`${phaseName} | Энтропия: ${escalationPct}%${speedInfo}${crisisInfo}`);
|
||||||
|
|
||||||
// Color phase text based on danger
|
// Color phase text based on danger
|
||||||
if (this.runState.phase >= RunPhase.Crisis) {
|
if (this.runState.phase >= RunPhase.Crisis) {
|
||||||
|
|||||||
147
tests/escalation.test.ts
Normal file
147
tests/escalation.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { RunState } from '../src/run/types';
|
||||||
|
import { RunPhase, ESCALATION_RATE } from '../src/run/types';
|
||||||
|
import { createRunState, advancePhase, updateEscalation } from '../src/run/state';
|
||||||
|
import {
|
||||||
|
getEscalationEffects,
|
||||||
|
type EscalationEffects,
|
||||||
|
} from '../src/run/escalation';
|
||||||
|
import {
|
||||||
|
createCrisisState,
|
||||||
|
applyCrisisDamage,
|
||||||
|
attemptNeutralize,
|
||||||
|
isCrisisResolved,
|
||||||
|
getCrisisPlayerDamage,
|
||||||
|
getCrisisTint,
|
||||||
|
CHEMICAL_PLAGUE,
|
||||||
|
} from '../src/run/crisis';
|
||||||
|
|
||||||
|
// ─── Escalation Effects ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Escalation Effects', () => {
|
||||||
|
it('at 0 escalation, all multipliers are 1.0', () => {
|
||||||
|
const fx = getEscalationEffects(0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.0);
|
||||||
|
expect(fx.creatureAggroRange).toBe(1.0);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBe(1.0);
|
||||||
|
expect(fx.reactionInstability).toBe(0);
|
||||||
|
expect(fx.environmentalDamage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at 0.5 escalation, effects are moderate', () => {
|
||||||
|
const fx = getEscalationEffects(0.5);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeLessThan(2.0);
|
||||||
|
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.reactionInstability).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at 1.0 escalation, effects are maximal', () => {
|
||||||
|
const fx = getEscalationEffects(1.0);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.5);
|
||||||
|
expect(fx.creatureAggroRange).toBe(1.8);
|
||||||
|
expect(fx.creatureAttackMultiplier).toBe(1.6);
|
||||||
|
expect(fx.reactionInstability).toBe(0.3);
|
||||||
|
expect(fx.environmentalDamage).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalation clamps values correctly', () => {
|
||||||
|
const fx = getEscalationEffects(2.0); // over max
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBe(1.5);
|
||||||
|
|
||||||
|
const fxNeg = getEscalationEffects(-1.0); // under min
|
||||||
|
expect(fxNeg.creatureSpeedMultiplier).toBe(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Crisis Damage ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Crisis Player Damage', () => {
|
||||||
|
it('no damage below 30% progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.2;
|
||||||
|
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damage increases with progress above 30%', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.5;
|
||||||
|
const dmg = getCrisisPlayerDamage(crisis, 1000);
|
||||||
|
expect(dmg).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damage scales with delta time', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
const dmg1 = getCrisisPlayerDamage(crisis, 1000);
|
||||||
|
const dmg2 = getCrisisPlayerDamage(crisis, 2000);
|
||||||
|
expect(dmg2).toBeCloseTo(dmg1 * 2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no damage when resolved', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
crisis.resolved = true;
|
||||||
|
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Crisis Visual Tint', () => {
|
||||||
|
it('returns no tint at 0 progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tint at high progress', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBeGreaterThan(0);
|
||||||
|
expect(tint.color).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no tint when resolved', () => {
|
||||||
|
const crisis = createCrisisState(CHEMICAL_PLAGUE);
|
||||||
|
crisis.progress = 0.8;
|
||||||
|
crisis.resolved = true;
|
||||||
|
const tint = getCrisisTint(crisis);
|
||||||
|
expect(tint.alpha).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Escalation-Phase Integration ────────────────────────────────
|
||||||
|
|
||||||
|
describe('Escalation Integration', () => {
|
||||||
|
let state: RunState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createRunState(1, 'alchemist');
|
||||||
|
state.phase = RunPhase.Escalation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalation grows steadily over time', () => {
|
||||||
|
// Simulate 30 seconds
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
updateEscalation(state, 1000);
|
||||||
|
}
|
||||||
|
expect(state.escalation).toBeCloseTo(ESCALATION_RATE * 30, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effects scale linearly with escalation', () => {
|
||||||
|
updateEscalation(state, 100_000); // ~100 seconds
|
||||||
|
const fx = getEscalationEffects(state.escalation);
|
||||||
|
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
|
||||||
|
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full escalation cycle reaches crisis threshold', () => {
|
||||||
|
// Simulate until threshold
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
updateEscalation(state, 1000);
|
||||||
|
}
|
||||||
|
expect(state.escalation).toBeGreaterThanOrEqual(CHEMICAL_PLAGUE.triggerThreshold);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user