diff --git a/src/run/crisis.ts b/src/run/crisis.ts index c7248ca..9de1bb8 100644 --- a/src/run/crisis.ts +++ b/src/run/crisis.ts @@ -83,3 +83,23 @@ export function attemptNeutralize( export function isCrisisResolved(crisis: CrisisState): boolean { 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), + }; +} diff --git a/src/run/escalation.ts b/src/run/escalation.ts new file mode 100644 index 0000000..e35f6e8 --- /dev/null +++ b/src/run/escalation.ts @@ -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; +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 51d80b1..1531d06 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -52,9 +52,12 @@ import { createCrisisState, applyCrisisDamage, attemptNeutralize, + getCrisisPlayerDamage, + getCrisisTint, CHEMICAL_PLAGUE, type CrisisState, } from '../run/crisis'; +import { getEscalationEffects } from '../run/escalation'; export class GameScene extends Phaser.Scene { private gameWorld!: GameWorld; @@ -396,14 +399,26 @@ export class GameScene extends Phaser.Scene { // 9g. Crisis damage (if active) if (this.crisisState?.active) { applyCrisisDamage(this.crisisState, delta); - // Crisis damages player slowly - if (this.crisisState.progress > 0.3) { - const crisisDmg = this.crisisState.progress * 0.5 * (delta / 1000); + const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta); + if (crisisDmg > 0) { Health.current[this.playerEid] = Math.max( 0, (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 @@ -493,8 +508,14 @@ export class GameScene extends Phaser.Scene { // Update phase indicator const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase]; const escalationPct = Math.round(this.runState.escalation * 100); - const crisisInfo = this.crisisState?.active ? ` | ЧУМА: ${Math.round(this.crisisState.progress * 100)}%` : ''; - this.phaseText.setText(`${phaseName} | Эскалация: ${escalationPct}%${crisisInfo}`); + const crisisInfo = this.crisisState?.active + ? ` | ☠ ЧУМА: ${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 if (this.runState.phase >= RunPhase.Crisis) { diff --git a/tests/escalation.test.ts b/tests/escalation.test.ts new file mode 100644 index 0000000..83bb15c --- /dev/null +++ b/tests/escalation.test.ts @@ -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); + }); +});