diff --git a/assets/nerf-doubleshotgun.png b/assets/nerf-doubleshotgun.png new file mode 100644 index 0000000..412b289 Binary files /dev/null and b/assets/nerf-doubleshotgun.png differ diff --git a/assets/nerf-gatlin.png b/assets/nerf-gatlin.png new file mode 100644 index 0000000..5e15f8d Binary files /dev/null and b/assets/nerf-gatlin.png differ diff --git a/assets/nerf-knife.png b/assets/nerf-knife.png new file mode 100644 index 0000000..0e5dc1e Binary files /dev/null and b/assets/nerf-knife.png differ diff --git a/assets/nerf-pistol.png b/assets/nerf-pistol.png new file mode 100644 index 0000000..4c4b07d Binary files /dev/null and b/assets/nerf-pistol.png differ diff --git a/assets/nerf-plasma.png b/assets/nerf-plasma.png new file mode 100644 index 0000000..4562fd9 Binary files /dev/null and b/assets/nerf-plasma.png differ diff --git a/assets/nerf-rifle.png b/assets/nerf-rifle.png new file mode 100644 index 0000000..1fcd604 Binary files /dev/null and b/assets/nerf-rifle.png differ diff --git a/assets/nerf-rocketlauncher.png b/assets/nerf-rocketlauncher.png new file mode 100644 index 0000000..05ab778 Binary files /dev/null and b/assets/nerf-rocketlauncher.png differ diff --git a/assets/nerf-shotgun.png b/assets/nerf-shotgun.png new file mode 100644 index 0000000..09b8817 Binary files /dev/null and b/assets/nerf-shotgun.png differ diff --git a/sprites.md b/sprites.md index 3573a4b..88c343a 100644 --- a/sprites.md +++ b/sprites.md @@ -79,39 +79,74 @@ Notes: handles sizing via `scale` and anchors the frame's bottom to the floor, so empty space at the bottom of the frame makes the item float. -## 4. `assets/sprites/enemies.png` — enemies +## 4. `assets/sprites/enemy_*.png` — enemies (one sheet per enemy) -**3 frames → 576×192 px. Transparent background required** — full-wall-height -billboards. +**13 frames → 2496×192 px each. Transparent background required** — +full-wall-height billboards. One PNG per enemy type: -| Frame | Pixels | Enemy (`enemies.json`) | Stats to suggest visually | -| ----- | ------- | ---------------------- | ------------------------------------ | -| 0 | 0–191 | `guard` | baseline: 50 hp, average speed | -| 1 | 192–383 | `heavy` | tanky: 120 hp, slow — bulkier build | -| 2 | 384–575 | `scout` | fragile: 25 hp, fast — lighter build | +| File | Enemy (`enemies.json`) | Stats to suggest visually | +| -------------------------------- | ---------------------- | ------------------------------------ | +| `assets/sprites/enemy_guard.png` | `guard` | baseline: 50 hp, average speed | +| `assets/sprites/enemy_heavy.png` | `heavy` | tanky: 120 hp, slow — bulkier build | +| `assets/sprites/enemy_scout.png` | `scout` | fragile: 25 hp, fast — lighter build | + +Every sheet uses the same fixed frame order (`ENEMY_FRAME_ORDER` in +`src/engine/registry.js`), offset from the enemy's `frame` base index in +`enemies.json` (0 for a dedicated file; a nonzero base lets several enemies +share one long strip): + +| Offset | Pose | Shown when | +| ------ | ----------- | --------------------------------------------------- | +| 0 | `front` | facing the player, standing | +| 1 | `frontWalk` | facing the player, walk-cycle alternate | +| 2 | `left` | facing left (its left shoulder toward you), standing| +| 3 | `leftWalk` | walk-cycle alternate | +| 4 | `right` | facing right, standing | +| 5 | `rightWalk` | walk-cycle alternate | +| 6 | `back` | facing away, standing | +| 7 | `backWalk` | walk-cycle alternate | +| 8 | `aim` | spotted the player, weapon raised (reaction window / between shots) | +| 9 | `fire` | flashed for ~150 ms after each shot | +| 10 | `pain` | flinch while hurt (~120 ms after a non-fatal hit) | +| 11 | `dying` | first 0.5 s after death (falling) | +| 12 | `dead` | corpse — held 3 s, then fades out over 1 s | Notes: -- One static frame per enemy for now (no walk/attack animation support yet — - when that lands, these will likely grow into multi-frame strips, which is - why keeping each enemy's frames contiguous is a good habit). -- Enemies are drawn at full wall height and stand on the floor; like items, - have the feet touch the bottom edge of the frame. -- The hurt flash is generated automatically (white-tinted copy) — you don't - need to draw one. +- Walk frames alternate with their standing pose while the enemy patrols + (cadence tracks the enemy's speed); `aim`/`fire` are **front-facing only** — + enemies turn to face the player whenever they engage. +- Enemies are drawn at full wall height and stand on the floor; have the feet + touch the bottom edge of the frame. The `dying`/`dead` frames live in the + same full-height square, so draw the corpse lying along the bottom edge. +- The hurt flash is generated automatically (white-tinted copy of the `pain` + frame) — you don't need to draw one. +- Any missing/short sheet falls back per-frame to pose-specific placeholder + art, so partial sheets are fine while you work. ## 5. `assets/sprites/pickups.png` — pickups & keys -**5 frames → 960×192 px. Transparent background required** — small +**16 frames → 3072×192 px. Transparent background required** — small floor-anchored billboards (drawn at 0.5 × wall height by default; add a `scale` field in `pickups.json` to change per pickup). -| Frame | Pixels | Pickup (`pickups.json`) | Placeholder it replaces | -| ----- | ------- | ------------------------ | ---------------------------- | -| 0 | 0–191 | `ammo` — Ammo | yellow diamond | -| 1 | 192–383 | `health` — Health | green diamond | -| 2 | 384–575 | `treasure` — Treasure | gold diamond | -| 3 | 576–767 | `key_gold` — Gold Key | gold key glyph | -| 4 | 768–959 | `key_silver` — Silver Key| silver key glyph | +| Frame | Pixels | Pickup (`pickups.json`) | Placeholder it replaces | +| ----- | --------- | -------------------------------- | ----------------------- | +| 0 | 0–191 | `ammo` — Ammo | yellow diamond | +| 1 | 192–383 | `health` — Health | green diamond | +| 2 | 384–575 | `treasure` — Treasure | gold diamond | +| 3 | 576–767 | `key_gold` — Gold Key | gold key glyph | +| 4 | 768–959 | `key_silver` — Silver Key | silver key glyph | +| 5 | 960–1151 | `ammo_mg` — MG Ammo | orange diamond | +| 6 | 1152–1343 | `ammo_gatling` — Gatling Ammo | orange diamond | +| 7 | 1344–1535 | `ammo_plasma` — Plasma Cells | teal diamond | +| 8 | 1536–1727 | `gun_machinegun` — Machine Gun | brown diamond | +| 9 | 1728–1919 | `gun_gatling` — Gatling Gun | rust diamond | +| 10 | 1920–2111 | `gun_plasma` — Plasma Gun | teal diamond | +| 11 | 2112–2303 | `ammo_rocket` — Rockets | red-orange diamond | +| 12 | 2304–2495 | `gun_rocket` — Rocket Launcher | dark-red diamond | +| 13 | 2496–2687 | `ammo_shells` — Shells | amber diamond | +| 14 | 2688–2879 | `gun_shotgun` — Shotgun | tan diamond | +| 15 | 2880–3071 | `gun_doubleshotgun` — Double Shotgun | brown diamond | Notes: - Key colors should match their door and their HUD chip (the chip uses the @@ -127,10 +162,11 @@ Notes: ## Adding more content later -New wall/overlay/item/enemy/pickup = one new 192×192 frame appended to the -relevant strip + one JSON entry in `src/config/` pointing at its `frame` -index. Nothing else. You can also start a fresh PNG per entry if you prefer — -the `texture` path is per-entry, not global. +New wall/overlay/item/pickup = one new 192×192 frame appended to the relevant +strip + one JSON entry in `src/config/` pointing at its `frame` index. A new +enemy = a fresh 13-frame sheet (see section 4) + its JSON entry. Nothing +else. You can also start a fresh PNG per entry if you prefer — the `texture` +path is per-entry, not global. ## Changing resolution later diff --git a/src/config/enemies.json b/src/config/enemies.json index 8f9f444..7073037 100644 --- a/src/config/enemies.json +++ b/src/config/enemies.json @@ -8,7 +8,9 @@ "health": 50, "speed": 1.5, "points": 100, - "texture": "assets/sprites/enemies.png", + "weapon": "pistol", + "skill": 4, + "texture": "assets/sprites/enemy_guard.png", "frame": 0 }, { @@ -18,8 +20,10 @@ "health": 120, "speed": 0.9, "points": 250, - "texture": "assets/sprites/enemies.png", - "frame": 1 + "weapon": "machinegun", + "skill": 5, + "texture": "assets/sprites/enemy_heavy.png", + "frame": 0 }, { "type": "scout", @@ -28,8 +32,10 @@ "health": 25, "speed": 2.6, "points": 150, - "texture": "assets/sprites/enemies.png", - "frame": 2 + "weapon": "pistol", + "skill": 3, + "texture": "assets/sprites/enemy_scout.png", + "frame": 0 } ] } diff --git a/src/config/pickups.json b/src/config/pickups.json index 11cbd4f..ceda5e9 100644 --- a/src/config/pickups.json +++ b/src/config/pickups.json @@ -5,6 +5,7 @@ "name": "Ammo", "color": "#d0d040", "effect": "ammo", + "weapon": "pistol", "amount": 10, "texture": "assets/sprites/pickups.png", "frame": 0 @@ -44,6 +45,110 @@ "key": "silver", "texture": "assets/sprites/pickups.png", "frame": 4 + }, + { + "id": "ammo_mg", + "name": "MG Ammo", + "color": "#ffb020", + "effect": "ammo", + "weapon": "machinegun", + "amount": 30, + "texture": "assets/sprites/pickups.png", + "frame": 5 + }, + { + "id": "ammo_gatling", + "name": "Gatling Ammo", + "color": "#ff8020", + "effect": "ammo", + "weapon": "gatling", + "amount": 60, + "texture": "assets/sprites/pickups.png", + "frame": 6 + }, + { + "id": "ammo_plasma", + "name": "Plasma Cells", + "color": "#40ffcc", + "effect": "ammo", + "weapon": "plasma", + "amount": 15, + "texture": "assets/sprites/pickups.png", + "frame": 7 + }, + { + "id": "gun_machinegun", + "name": "Machine Gun", + "color": "#c08030", + "effect": "weapon", + "weapon": "machinegun", + "texture": "assets/sprites/pickups.png", + "frame": 8 + }, + { + "id": "gun_gatling", + "name": "Gatling Gun", + "color": "#c05020", + "effect": "weapon", + "weapon": "gatling", + "texture": "assets/sprites/pickups.png", + "frame": 9 + }, + { + "id": "gun_plasma", + "name": "Plasma Gun", + "color": "#20c0a0", + "effect": "weapon", + "weapon": "plasma", + "texture": "assets/sprites/pickups.png", + "frame": 10 + }, + { + "id": "ammo_rocket", + "name": "Rockets", + "color": "#ff6030", + "effect": "ammo", + "weapon": "rocket", + "amount": 5, + "texture": "assets/sprites/pickups.png", + "frame": 11 + }, + { + "id": "gun_rocket", + "name": "Rocket Launcher", + "color": "#c04020", + "effect": "weapon", + "weapon": "rocket", + "texture": "assets/sprites/pickups.png", + "frame": 12 + }, + { + "id": "ammo_shells", + "name": "Shells", + "color": "#ffc860", + "effect": "ammo", + "weapon": "shotgun", + "amount": 8, + "texture": "assets/sprites/pickups.png", + "frame": 13 + }, + { + "id": "gun_shotgun", + "name": "Shotgun", + "color": "#b09050", + "effect": "weapon", + "weapon": "shotgun", + "texture": "assets/sprites/pickups.png", + "frame": 14 + }, + { + "id": "gun_doubleshotgun", + "name": "Double Shotgun", + "color": "#907040", + "effect": "weapon", + "weapon": "doubleshotgun", + "texture": "assets/sprites/pickups.png", + "frame": 15 } ] } diff --git a/src/config/weapons.json b/src/config/weapons.json new file mode 100644 index 0000000..b5e73a0 --- /dev/null +++ b/src/config/weapons.json @@ -0,0 +1,129 @@ +{ + "defaultWeapon": "pistol", + "weapons": [ + { + "id": "melee", + "name": "Knife", + "slot": 1, + "damage": 50, + "cooldownMs": 400, + "spreadDeg": 0, + "bulletSpeed": null, + "range": 1.2, + "ammoCost": 0, + "startAmmo": 0, + "pickupAmmo": 0, + "projectile": null, + "hudImage": "assets/nerf-knife.png" + }, + { + "id": "pistol", + "name": "Pistol", + "slot": 2, + "damage": 25, + "cooldownMs": 450, + "spreadDeg": 0.8, + "bulletSpeed": 20, + "range": 20, + "ammoCost": 1, + "startAmmo": 24, + "pickupAmmo": 12, + "projectile": { "color": "#ffd040", "size": 0.08 }, + "hudImage": "assets/nerf-pistol.png" + }, + { + "id": "machinegun", + "name": "Machine Gun", + "slot": 3, + "damage": 12, + "cooldownMs": 120, + "spreadDeg": 3, + "bulletSpeed": 18, + "range": 18, + "ammoCost": 1, + "startAmmo": 0, + "pickupAmmo": 40, + "projectile": { "color": "#ffb020", "size": 0.08 }, + "hudImage": "assets/nerf-rifle.png" + }, + { + "id": "gatling", + "name": "Gatling Gun", + "slot": 4, + "damage": 10, + "cooldownMs": 60, + "spreadDeg": 6, + "bulletSpeed": 18, + "range": 16, + "ammoCost": 1, + "startAmmo": 0, + "pickupAmmo": 80, + "projectile": { "color": "#ff8020", "size": 0.07 }, + "hudImage": "assets/nerf-gatlin.png" + }, + { + "id": "plasma", + "name": "Plasma Gun", + "slot": 5, + "damage": 60, + "cooldownMs": 700, + "spreadDeg": 1, + "bulletSpeed": 8, + "range": 24, + "ammoCost": 2, + "startAmmo": 0, + "pickupAmmo": 20, + "projectile": { "color": "#40ffcc", "size": 0.18 }, + "hudImage": "assets/nerf-plasma.png" + }, + { + "id": "rocket", + "name": "Rocket Launcher", + "slot": 6, + "damage": 100, + "cooldownMs": 1000, + "spreadDeg": 0.3, + "bulletSpeed": 6, + "range": 24, + "ammoCost": 1, + "startAmmo": 0, + "pickupAmmo": 5, + "projectile": { "color": "#ff6030", "size": 0.22 }, + "explosion": { "radius": 2, "damage": 80 }, + "hudImage": "assets/nerf-rocketlauncher.png" + }, + { + "id": "shotgun", + "name": "Shotgun", + "slot": 7, + "damage": 8, + "pellets": 8, + "cooldownMs": 900, + "spreadDeg": 5, + "bulletSpeed": 18, + "range": 10, + "ammoCost": 1, + "startAmmo": 0, + "pickupAmmo": 8, + "projectile": { "color": "#ffc860", "size": 0.055 }, + "hudImage": "assets/nerf-shotgun.png" + }, + { + "id": "doubleshotgun", + "name": "Double Shotgun", + "slot": 8, + "damage": 8, + "pellets": 16, + "cooldownMs": 1500, + "spreadDeg": 11, + "bulletSpeed": 18, + "range": 9, + "ammoCost": 2, + "ammoId": "shotgun", + "startAmmo": 0, + "pickupAmmo": 8, + "projectile": { "color": "#ffc860", "size": 0.055 }, + "hudImage": "assets/nerf-doubleshotgun.png" + } + ] +} diff --git a/src/engine/Entity.js b/src/engine/Entity.js index a82cc38..0ce6c00 100644 --- a/src/engine/Entity.js +++ b/src/engine/Entity.js @@ -1,9 +1,15 @@ -import { getEntityDef } from './registry.js'; +import { getEntityDef, getWeaponDef } from './registry.js'; +import { + skillSpreadMult, + skillReactionMs, + skillCooldownMs, +} from './Projectile.js'; /** - * A world entity: enemy or pickup. Holds a stub state machine good enough to - * validate sprites + shooting now. Full enemy AI (patrol/chase/attack) plugs - * into `update()` and the `state` transitions later. + * A world entity: enemy or pickup. Enemies patrol authored waypoint loops and, + * if their type carries a weapon (enemies.json), engage the player on sight: + * stop, take a skill-based moment to react, then fire real projectiles whose + * spread/cadence also scale with skill (1 poor … 10 perfect). */ export class Entity { constructor(spawn) { @@ -14,9 +20,19 @@ export class Entity { getEntityDef(spawn.type) ?? { type: spawn.type, name: spawn.type, color: 0xff00ff, isEnemy: false, health: 0 }; this.health = this.def.health; - this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected' - /** Ms remaining in a transient state (e.g. the hurt flinch flash). */ + this.state = 'idle'; // 'idle' | 'hurt' | 'dying' | 'dead' | 'gone' | 'collected' + /** Ms remaining in the current timed state (hurt flinch, dying fall, or + * the dead corpse — which runs negative to drive the fade-out). */ this.stateTimer = 0; + /** Render opacity; the corpse fades 1 → 0 before the entity goes away. */ + this.alpha = 1; + + // Combat loadout (armed enemies only; null = patrol-only, never fires). + this.weapon = this.def.isEnemy ? getWeaponDef(this.def.weapon) : null; + this.engaged = false; // has line of sight and is standing to fight + this.reactionTimer = 0; // ms until the first shot after spotting the player + this.attackCooldown = 0; // ms until the next shot while engaged + this.fireTimer = 0; // ms the muzzle-flash 'fire' frame stays up after a shot // Patrol route (enemies only): the spawn point plus the level-authored // path points, walked in a loop at the def's speed (cells/sec). @@ -26,23 +42,57 @@ export class Entity { ? [{ x: spawn.x, y: spawn.y }, ...path.map((p) => ({ x: p.x, y: p.y }))] : null; this.wpIndex = 1; // current target (0 is the spawn point) + + // Facing (unit vector) drives which directional sprite is shown: patrol + // steps update it, engaging turns it to the player. Start toward the + // first waypoint, or south for enemies posted without a route. + let fx = 0; + let fy = 1; + if (this.waypoints) { + const t = this.waypoints[this.wpIndex]; + const d = Math.hypot(t.x - this.x, t.y - this.y); + if (d > 0) { + fx = (t.x - this.x) / d; + fy = (t.y - this.y) / d; + } + } + this.facingX = fx; + this.facingY = fy; + this.moving = false; // walked this frame → show the alternating walk frame + this.walkFlip = false; // which of the two walk-cycle frames is up + this.walkDist = 0; // distance since the last walk-frame flip (cells) } get isEnemy() { return this.def.isEnemy; } - /** Still rendered and interactable? Dead enemies and collected items aren't. */ + /** Still rendered and updated? Faded-out corpses and collected items aren't. + * Note a dead enemy IS still active (its corpse renders) — combat and hit + * tests must check `alive` instead. */ get active() { - return this.state !== 'dead' && this.state !== 'collected'; + return this.state !== 'gone' && this.state !== 'collected'; + } + + /** Enemy still fighting (not dying/dead)? Hit tests, combat, patrol, and + * the level-complete check use this so corpses don't soak bullets. */ + get alive() { + return ( + this.isEnemy && + this.state !== 'dying' && + this.state !== 'dead' && + this.state !== 'gone' + ); } /** Apply damage (enemies only). Returns true if this shot killed it. */ damage(amount) { - if (!this.isEnemy || !this.active) return false; + if (!this.alive) return false; this.health -= amount; if (this.health <= 0) { - this.state = 'dead'; + this.state = 'dying'; + this.stateTimer = Entity.DYING_MS; + this.engaged = false; return true; } this.state = 'hurt'; @@ -54,22 +104,97 @@ export class Entity { if (!this.isEnemy) this.state = 'collected'; } - update(deltaMs) { + update(deltaMs, world) { + if (this.fireTimer > 0) this.fireTimer -= deltaMs; + this.moving = false; + + // Death sequence: dying pose → corpse → fade to nothing. + if (this.state === 'dying') { + this.stateTimer -= deltaMs; + if (this.stateTimer <= 0) { + this.state = 'dead'; + this.stateTimer = Entity.DEAD_MS; + } + return; + } + if (this.state === 'dead') { + // The timer keeps counting below zero; the negative part drives the fade. + this.stateTimer -= deltaMs; + this.alpha = Math.min(1, Math.max(0, 1 + this.stateTimer / Entity.FADE_MS)); + if (this.stateTimer <= -Entity.FADE_MS) this.state = 'gone'; + return; + } + if (this.stateTimer > 0) { this.stateTimer -= deltaMs; if (this.stateTimer <= 0 && this.state === 'hurt') { this.state = 'idle'; } } - this.updatePatrol(deltaMs); - // TODO: further enemy AI (chase/attack/pathfinding) hooks in here. + this.updateCombat(deltaMs, world); + // Engaged enemies stand their ground and shoot instead of patrolling. + if (!this.engaged) this.updatePatrol(deltaMs); + } + + /** Sight-driven attack: engage while the player is in weapon range with a + * clear line of sight; disengage (and resume patrol) when either fails. + * Getting shot (hurt flinch) pauses the trigger finger like it pauses + * patrol. */ + updateCombat(deltaMs, world) { + if (!this.weapon || !this.alive || !world) { + this.engaged = false; + return; + } + const { cam } = world.player; + const dist = Math.hypot(cam.posX - this.x, cam.posY - this.y); + const seesPlayer = + dist <= this.weapon.range && + world.map.hasLineOfSight(this.x, this.y, cam.posX, cam.posY); + + if (!seesPlayer) { + this.engaged = false; + return; + } + if (!this.engaged) { + this.engaged = true; + this.reactionTimer = skillReactionMs(this.def.skill); + this.attackCooldown = 0; + } + // Fighting enemies square up to the player (they only shoot forward). + this.facingX = (cam.posX - this.x) / (dist || 1); + this.facingY = (cam.posY - this.y) / (dist || 1); + + if (this.reactionTimer > 0) { + this.reactionTimer -= deltaMs; + return; + } + this.attackCooldown -= deltaMs; + if (this.attackCooldown > 0 || this.state === 'hurt') return; + this.attackCooldown = skillCooldownMs(this.weapon, this.def.skill); + this.fireTimer = Entity.FIRE_FLASH_MS; + + // Shotgun-armed enemies fire the full pellet cloud, like the player does. + for (let i = 0; i < this.weapon.pellets; i++) { + world.spawnProjectile({ + x: this.x, + y: this.y, + dirX: (cam.posX - this.x) / (dist || 1), + dirY: (cam.posY - this.y) / (dist || 1), + weapon: this.weapon, + owner: this, + // Even a laser-accurate gun can miss in enemy hands: floor the base + // spread at 1° before the skill multiplier. + spreadDeg: Math.max(this.weapon.spreadDeg, 1), + spreadMult: skillSpreadMult(this.def.skill), + }); + } } /** Walk the authored patrol loop. Getting shot (hurt flinch) pauses it. * Paths are author-trusted: no collision checks, so a path drawn through * a wall will walk through it. */ updatePatrol(deltaMs) { - if (!this.waypoints || !this.active || this.state === 'hurt') return; + if (!this.waypoints || !this.alive || this.state === 'hurt') return; const target = this.waypoints[this.wpIndex]; const step = (this.def.speed ?? 1.5) * (deltaMs / 1000); const dx = target.x - this.x; @@ -83,5 +208,58 @@ export class Entity { this.x += (dx / dist) * step; this.y += (dy / dist) * step; } + if (dist > 0) { + this.facingX = dx / dist; + this.facingY = dy / dist; + } + // Two-frame walk cycle, flipped by distance covered so the cadence + // naturally tracks the enemy's speed. + this.moving = true; + this.walkDist += Math.min(step, dist); + if (this.walkDist >= Entity.WALK_STRIDE) { + this.walkFlip = !this.walkFlip; + this.walkDist = 0; + } + } + + /** The texture to billboard this frame — the enemy sprite state machine. + * Non-enemies (and enemies from configs without frame strips) fall back to + * the single static def.tex. */ + getTex(cam) { + const f = this.def.frames; + if (!this.isEnemy || !f) { + return this.state === 'hurt' ? this.def.texFlash ?? this.def.tex : this.def.tex; + } + if (this.state === 'dying') return f.dying; + if (this.state === 'dead') return f.dead; + if (this.state === 'hurt') return this.def.texFlash ?? f.pain; + if (this.engaged) { + if (this.fireTimer > 0) return f.fire; + return f.aim; + } + // Directional pose: quantize the angle between our facing and the line to + // the camera into front / back / left / right quadrants. + const toCamX = cam.posX - this.x; + const toCamY = cam.posY - this.y; + const len = Math.hypot(toCamX, toCamY) || 1; + const dot = (this.facingX * toCamX + this.facingY * toCamY) / len; + let key; + if (dot >= Math.SQRT1_2) key = 'front'; + else if (dot <= -Math.SQRT1_2) key = 'back'; + else { + // With y pointing south (grid rows), facing×toCam > 0 puts the camera + // on the enemy's right-hand side. + const cross = this.facingX * toCamY - this.facingY * toCamX; + key = cross > 0 ? 'right' : 'left'; + } + if (this.moving && this.walkFlip) key += 'Walk'; + return f[key]; } } + +// Death-sequence and animation pacing (ms / cells). +Entity.DYING_MS = 500; // dying pose before the corpse appears +Entity.DEAD_MS = 3000; // corpse shown at full opacity +Entity.FADE_MS = 1000; // corpse fade-out after DEAD_MS +Entity.FIRE_FLASH_MS = 150; // 'fire' frame held after each shot +Entity.WALK_STRIDE = 0.4; // cells walked between walk-cycle frame flips diff --git a/src/engine/GameMap.js b/src/engine/GameMap.js index 03f7a40..96328f6 100644 --- a/src/engine/GameMap.js +++ b/src/engine/GameMap.js @@ -77,6 +77,20 @@ export class GameMap { return door ? door.state !== 'open' : true; } + /** March a coarse ray between two points; false if any wall blocks it. + * Shared by the player's melee check and enemy target acquisition. */ + hasLineOfSight(x0, y0, x1, y1) { + const dx = x1 - x0; + const dy = y1 - y0; + const dist = Math.hypot(dx, dy); + const steps = Math.ceil(dist * 8); + for (let i = 1; i < steps; i++) { + const t = i / steps; + if (this.isWall(x0 + dx * t, y0 + dy * t)) return false; + } + return true; + } + /** Whether movement is blocked here — by a wall or by a blocking item. */ isBlocked(x, y) { if (this.isWall(x, y)) return true; diff --git a/src/engine/Player.js b/src/engine/Player.js index 881d9d5..2b2b145 100644 --- a/src/engine/Player.js +++ b/src/engine/Player.js @@ -2,26 +2,40 @@ import { FOV, MOVE_SPEED, PLAYER_RADIUS, - WEAPON_RANGE, - WEAPON_DAMAGE, - START_AMMO, START_HEALTH, } from './constants.js'; +import { getWeaponDef, getDefaultWeaponId, listWeapons } from './registry.js'; /** * The player: a camera (position + direction/plane vectors) plus movement with - * grid collision and a simple hitscan weapon. Direction is stored as vectors so - * it feeds the raycaster directly with no per-frame trig in the hot loop. + * grid collision and a weapon loadout. Direction is stored as vectors so it + * feeds the raycaster directly with no per-frame trig in the hot loop. + * + * Weapons are data-driven (weapons.json). The player starts with melee plus + * the default weapon; the rest are acquired as level pickups. Ammo is tracked + * per weapon id. Ranged firing itself lives in GameScene (it owns the + * projectile list); melee is resolved here instantly. */ export class Player { constructor(start) { this.health = START_HEALTH; - this.ammo = START_AMMO; this.score = 0; /** Key ids collected this level (e.g. 'gold') — opens matching doors. * Classic raycaster rules: keys reset on every level. */ this.keys = new Set(); + /** Owned weapon ids. Melee is always carried; the default weapon (pistol) + * is the starting gun; others come from 'weapon' pickups. */ + this.weapons = new Set(['melee', getDefaultWeaponId()]); + /** Rounds per ammo pool (weapon `ammoId`, usually the weapon id — the + * shotguns share one shell pool). Unowned guns hold 0 until picked up. */ + this.ammo = {}; + for (const w of listWeapons()) { + this.ammo[w.ammoId] = + (this.ammo[w.ammoId] ?? 0) + (this.weapons.has(w.id) ? w.startAmmo : 0); + } + this.weapon = getWeaponDef(getDefaultWeaponId()); + const dirX = Math.cos(start.dir); const dirY = Math.sin(start.dir); // Camera plane is perpendicular to dir; its length sets the FOV. @@ -36,6 +50,46 @@ export class Player { }; } + /** Switch to an owned weapon. Returns false (no switch) if not owned. */ + selectWeapon(id) { + const def = getWeaponDef(id); + if (!def || !this.weapons.has(id)) return false; + this.weapon = def; + return true; + } + + /** Grant a weapon (pickup) along with its bundled ammo. */ + addWeapon(id) { + const def = getWeaponDef(id); + if (!def) return false; + this.weapons.add(id); + this.ammo[def.ammoId] = (this.ammo[def.ammoId] ?? 0) + def.pickupAmmo; + return true; + } + + /** Add rounds to a weapon's pool. `id` may be any weapon id — it resolves + * to that weapon's ammoId, so shell pickups feed both shotguns. */ + addAmmo(id, amount) { + const key = getWeaponDef(id)?.ammoId ?? id; + this.ammo[key] = (this.ammo[key] ?? 0) + amount; + } + + /** Spend the current weapon's ammo cost; false if the pool is short. */ + tryConsumeAmmo() { + const cost = this.weapon.ammoCost; + const key = this.weapon.ammoId; + if ((this.ammo[key] ?? 0) < cost) return false; + this.ammo[key] -= cost; + return true; + } + + /** Apply incoming damage. Returns true if this reduced health to zero. */ + takeDamage(amount) { + if (this.health <= 0) return false; + this.health = Math.max(0, this.health - amount); + return this.health <= 0; + } + /** Apply rotation by `angle` radians, rotating both dir and plane vectors. */ rotate(angle) { const c = Math.cos(angle); @@ -77,50 +131,34 @@ export class Player { } } - /** Fire a hitscan shot down the center of view. Picks the nearest active - * enemy that is both in range and roughly under the crosshair, and that has - * clear line of sight (no wall between). */ - shoot(map, entities) { - if (this.ammo <= 0) return { fired: false, hit: null, killed: false }; - this.ammo--; - + /** Instant melee swing: hits the nearest active enemy within the knife's + * short reach and a wide frontal arc (the crosshair cone would be only + * ~20 cm wide at this range). Rays don't leave the room: line of sight is + * still required so a wall — or a closed door — blocks the swing. */ + melee(map, entities) { + const w = this.weapon; const { cam } = this; let best = null; - let bestDist = WEAPON_RANGE; + let bestDist = w.range; for (const e of entities) { - if (!e.isEnemy || !e.active) continue; + if (!e.alive) continue; // enemies only, and not dying/dead ones const dx = e.x - cam.posX; const dy = e.y - cam.posY; const dist = Math.hypot(dx, dy); - if (dist > WEAPON_RANGE) continue; + if (dist > w.range) continue; - // Angle between view direction and the direction to the entity. const dot = (dx * cam.dirX + dy * cam.dirY) / (dist || 1); - if (dot < 0.985) continue; // ~10° aim cone + if (dot < 0.7) continue; // ~±45° swing arc - if (dist < bestDist && this.hasLineOfSight(map, e)) { + if (dist <= bestDist && map.hasLineOfSight(cam.posX, cam.posY, e.x, e.y)) { best = e; bestDist = dist; } } - if (!best) return { fired: true, hit: null, killed: false }; - const killed = best.damage(WEAPON_DAMAGE); - return { fired: true, hit: best, killed }; - } - - /** March a coarse ray from the player to the entity; blocked by any wall. */ - hasLineOfSight(map, e) { - const { cam } = this; - const dx = e.x - cam.posX; - const dy = e.y - cam.posY; - const dist = Math.hypot(dx, dy); - const steps = Math.ceil(dist * 8); - for (let i = 1; i < steps; i++) { - const t = i / steps; - if (map.isWall(cam.posX + dx * t, cam.posY + dy * t)) return false; - } - return true; + if (!best) return { fired: true, hit: null, killed: false, melee: true }; + const killed = best.damage(w.damage); + return { fired: true, hit: best, killed, melee: true }; } } diff --git a/src/engine/Projectile.js b/src/engine/Projectile.js new file mode 100644 index 0000000..b728d4a --- /dev/null +++ b/src/engine/Projectile.js @@ -0,0 +1,161 @@ +/** + * A bullet (or plasma bolt) tracked in world space. Unlike classic Wolf3D + * hitscan, shots fly at the weapon's bulletSpeed and only deal damage when + * they actually collide with a wall, an enemy, or the player. Shots deviate + * from the aim direction by the weapon's spread (scaled up for low-skill + * enemies), so accuracy is emergent: a wide shot simply misses. Weapons with + * an `explosion` def (rocket launcher) also splash-damage everything around + * their impact point — see explode(). + * + * Quacks like a renderable for SpriteRenderer: { x, y, active, isEnemy, + * def: { tex, scale, anchor: 'center' } } — drawn as a small billboard at + * eye height, occluded by walls via the zBuffer like any sprite. + */ + +/** Max cells a projectile may advance per collision sample. Smaller than the + * hit radii and any wall tile, so fast bullets can't tunnel through either. */ +const MAX_STEP = 0.25; + +/** Collision radii (cells): entities are torso-sized, the player a bit + * slimmer so incoming fire is dodgeable. */ +const ENEMY_HIT_RADIUS = 0.35; +const PLAYER_HIT_RADIUS = 0.3; + +// --- Skill (1–10) → shooting-quality helpers. One source of truth, used by +// --- Entity (enemy fire) and available to tests. + +/** Spread multiplier: skill 10 shoots at the weapon's listed spread, + * skill 1 at ~4.15×. */ +export function skillSpreadMult(skill) { + return 1 + (10 - skill) * 0.35; +} + +/** Delay between spotting the player and the first shot. */ +export function skillReactionMs(skill) { + return (11 - skill) * 120; +} + +/** Low-skill enemies also fire slower than the weapon allows. */ +export function skillCooldownMs(weapon, skill) { + return weapon.cooldownMs * (1 + (10 - skill) * 0.15); +} + +/** Rotate the unit vector (dirX, dirY) by a uniform random angle within + * ±spreadDeg × spreadMult degrees. */ +export function spreadDir(dirX, dirY, spreadDeg, spreadMult = 1) { + const maxRad = (spreadDeg * spreadMult * Math.PI) / 180; + if (maxRad === 0) return { x: dirX, y: dirY }; + const a = (Math.random() * 2 - 1) * maxRad; + const c = Math.cos(a); + const s = Math.sin(a); + return { x: dirX * c - dirY * s, y: dirX * s + dirY * c }; +} + +export class Projectile { + /** owner is the shooter (the Player or an Entity): it selects the target + * set — player bullets hit enemies, enemy bullets hit the player — which + * also inherently prevents self-hits and enemy friendly fire. */ + constructor({ x, y, dirX, dirY, weapon, owner, spreadMult = 1, spreadDeg = weapon.spreadDeg }) { + const dir = spreadDir(dirX, dirY, spreadDeg, spreadMult); + // Muzzle offset along the true aim so the bolt doesn't clip the shooter. + this.x = x + dirX * 0.3; + this.y = y + dirY * 0.3; + this.dx = dir.x; + this.dy = dir.y; + this.speed = weapon.bulletSpeed; + this.damage = weapon.damage; + this.rangeLeft = weapon.range; + this.owner = owner; + this.fromPlayer = !!owner?.cam; + this.active = true; + + // Splash spec ({ radius, damage } or null) and the blast art, both from + // the weapon def. Explosive shots detonate wherever they stop. + this.explosion = weapon.explosion ?? null; + this.explosionTex = weapon.explosionTex ?? null; + + // SpriteRenderer contract (anchor 'center' = eye height, not floor). + this.isEnemy = false; + this.def = { tex: weapon.projTex, scale: weapon.projectile?.size ?? 0.08, anchor: 'center' }; + + // Set when this projectile hits something, for GameScene to react to. + // Arrays/totals because one explosion can kill several enemies and hurt + // the player (splash ignores ownership — even the shooter isn't safe). + this.kills = []; // enemies killed by this shot, for score credit + this.playerDamage = 0; // total damage dealt to the player + this.exploded = null; // { x, y, radius } once detonated, for the blast VFX + } + + /** Advance by dt seconds through `world` = { map, entities, player }, + * substepped so no sample exceeds MAX_STEP cells. */ + update(dt, world) { + if (!this.active) return; + const travel = Math.min(this.speed * dt, this.rangeLeft); + const steps = Math.max(1, Math.ceil(travel / MAX_STEP)); + const stepLen = travel / steps; + + for (let i = 0; i < steps; i++) { + this.x += this.dx * stepLen; + this.y += this.dy * stepLen; + this.rangeLeft -= stepLen; + + if (world.map.isWall(this.x, this.y)) { + // Back out of the wall so a detonation isn't buried inside the cell + // (hasLineOfSight from within a wall would block all its splash). + this.x -= this.dx * stepLen; + this.y -= this.dy * stepLen; + this.impact(world); + return; + } + + if (this.fromPlayer) { + for (const e of world.entities) { + if (!e.alive) continue; // corpses and dying enemies don't soak hits + if (Math.hypot(e.x - this.x, e.y - this.y) >= ENEMY_HIT_RADIUS) continue; + if (e.damage(this.damage)) this.kills.push(e); + this.impact(world); + return; + } + } else { + const { cam } = world.player; + if (Math.hypot(cam.posX - this.x, cam.posY - this.y) < PLAYER_HIT_RADIUS) { + this.playerDamage += this.damage; + this.impact(world); + return; + } + } + } + + if (this.rangeLeft <= 0) this.impact(world); + } + + /** Stop here; direct-hit damage was already applied by the caller. Explosive + * warheads add their splash on top (a direct hit deals both). */ + impact(world) { + this.active = false; + if (this.explosion) this.explode(world); + } + + /** Splash damage to everything alive within the blast radius — enemies and + * the player alike, shooter included — falling off linearly from full + * damage at the center to zero at the edge. Walls (and closed doors) + * shield anything without line of sight to the blast. */ + explode(world) { + const { radius, damage } = this.explosion; + this.exploded = { x: this.x, y: this.y, radius }; + + const splash = (tx, ty) => { + const d = Math.hypot(tx - this.x, ty - this.y); + if (d >= radius || !world.map.hasLineOfSight(this.x, this.y, tx, ty)) return 0; + return Math.round(damage * (1 - d / radius)); + }; + + for (const e of world.entities) { + if (!e.alive) continue; + const dmg = splash(e.x, e.y); + if (dmg > 0 && e.damage(dmg)) this.kills.push(e); + } + const { cam } = world.player; + this.playerDamage += splash(cam.posX, cam.posY); + } +} diff --git a/src/engine/SpriteRenderer.js b/src/engine/SpriteRenderer.js index 37ae01c..e27368c 100644 --- a/src/engine/SpriteRenderer.js +++ b/src/engine/SpriteRenderer.js @@ -8,7 +8,9 @@ import { TEX_SIZE } from './registry.js'; * raycaster's ZBuffer so walls correctly occlude sprites. * * A renderable needs: x, y, active, isEnemy, and a def with tex (+ optional - * texFlash and scale). Entities and GameScene's item props both fit. + * texFlash and scale). Entities and GameScene's item props both fit. A + * renderable may also provide getTex(cam) to pick its frame per-frame (enemy + * animation) and alpha (0–1) for fading. */ export class SpriteRenderer { constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) { @@ -48,8 +50,10 @@ export class SpriteRenderer { const spriteW = spriteH; // square billboards if (spriteH < 1) continue; // too far away to draw - // Anchor items/pickups to the floor; center enemies vertically. - const vMove = e.isEnemy ? 0 : fullH / 2 - spriteH / 2; + // Anchor items/pickups to the floor; center enemies vertically, and + // anything opting into anchor 'center' (projectiles fly at eye height). + const vMove = + e.isEnemy || e.def?.anchor === 'center' ? 0 : fullH / 2 - spriteH / 2; const centerY = this.height / 2 + vMove; // Unclamped bounds — the canvas clips vertically, keeping the 1:1 // mapping between source texture rows and on-screen pixels. @@ -57,9 +61,17 @@ export class SpriteRenderer { const drawStartX = Math.floor(screenX - spriteW / 2); const drawEndX = Math.floor(screenX + spriteW / 2); - const tex = e.state === 'hurt' ? e.def.texFlash : e.def?.tex; + // Animated renderables (enemies) pick their frame themselves; everything + // else shows its single baked texture (+ white flash while hurt). + const tex = e.getTex + ? e.getTex(cam) + : e.state === 'hurt' ? e.def.texFlash : e.def?.tex; if (!tex) continue; // registry not baked yet (first frames / tests) + const alpha = e.alpha ?? 1; // fading corpses draw translucent + if (alpha <= 0) continue; + if (alpha < 1) this.ctx.globalAlpha = alpha; + // Per-column occlusion: only draw stripes nearer than the wall there. for (let stripe = drawStartX; stripe <= drawEndX; stripe++) { if (stripe < 0 || stripe >= this.width) continue; @@ -74,6 +86,7 @@ export class SpriteRenderer { stripe, drawStartY, 1, spriteH, ); } + if (alpha < 1) this.ctx.globalAlpha = 1; } } } diff --git a/src/engine/constants.js b/src/engine/constants.js index 059d00f..b0af08e 100644 --- a/src/engine/constants.js +++ b/src/engine/constants.js @@ -29,12 +29,12 @@ export const PLAYER_RADIUS = 0.2; // collision radius in cell units // --------------------------------------------------------------------------- // Combat tuning // --------------------------------------------------------------------------- +// Per-weapon stats (damage, spread, bullet speed, rate of fire, ammo) live in +// src/config/weapons.json; enemy marksmanship lives in enemies.json (skill). -export const WEAPON_RANGE = 12; // cells -export const WEAPON_DAMAGE = 25; -export const WEAPON_FIRE_COOLDOWN = 250; // ms between shots -export const START_AMMO = 50; export const START_HEALTH = 100; +/** How long the "YOU DIED" overlay shows before the level restarts. */ +export const DEATH_RESTART_MS = 2000; // --------------------------------------------------------------------------- // Door tuning diff --git a/src/engine/registry.js b/src/engine/registry.js index 3d23f04..bd0bf2b 100644 --- a/src/engine/registry.js +++ b/src/engine/registry.js @@ -12,16 +12,33 @@ import { CONFIG_DIR } from './constants.js'; * Textures: each definition may name a spritesheet `texture` (path relative to * the HTML page) and a `frame` index into a horizontal strip of square frames. * At init every definition gets a baked TEX_SIZE×TEX_SIZE canvas in `def.tex` - * (enemies also `def.texFlash` for the hurt flash) — cut from the sheet when - * the image exists, otherwise a procedurally generated placeholder in the - * definition's color. Renderers can therefore drawImage(def.tex, …) - * unconditionally; dropping real PNGs into assets/ requires no code changes. + * — cut from the sheet when the image exists, otherwise a procedurally + * generated placeholder in the definition's color. Enemies instead get a full + * `def.frames` map (one canvas per ENEMY_FRAME_ORDER pose, `frame` being the + * strip's base index), plus `def.texFlash` (white-tinted pain pose for the + * hurt flash) and `def.tex` aliasing the front pose. Renderers can therefore + * drawImage(def.tex, …) unconditionally; dropping real PNGs into assets/ + * requires no code changes. */ /** Square size (px) of all baked texture canvases (and the default source * frame size for spritesheets — see sprites.md). */ export const TEX_SIZE = 192; +/** Per-enemy animation frames, in spritesheet order: each enemy's sheet is a + * horizontal strip of these 13 frames starting at its `frame` base index + * (see sprites.md). Directional frames are chosen by the enemy's facing + * relative to the camera; `*Walk` alternates with its base frame while + * moving; aim/fire are front-only (enemies turn to the player to shoot). */ +export const ENEMY_FRAME_ORDER = [ + 'front', 'frontWalk', + 'left', 'leftWalk', + 'right', 'rightWalk', + 'back', 'backWalk', + 'aim', 'fire', + 'pain', 'dying', 'dead', +]; + // --------------------------------------------------------------------------- // Embedded fallback config // --------------------------------------------------------------------------- @@ -61,9 +78,9 @@ const DEFAULT_ITEMS = { const DEFAULT_ENEMIES = { defaultType: 'guard', enemies: [ - { type: 'guard', name: 'Guard', color: '#b03030', health: 50, speed: 1.5, points: 100, texture: 'assets/sprites/enemies.png', frame: 0 }, - { type: 'heavy', name: 'Heavy', color: '#803030', health: 120, speed: 0.9, points: 250, texture: 'assets/sprites/enemies.png', frame: 1 }, - { type: 'scout', name: 'Scout', color: '#d06060', health: 25, speed: 2.6, points: 150, texture: 'assets/sprites/enemies.png', frame: 2 }, + { type: 'guard', name: 'Guard', color: '#b03030', health: 50, speed: 1.5, points: 100, weapon: 'pistol', skill: 4, texture: 'assets/sprites/enemy_guard.png', frame: 0 }, + { type: 'heavy', name: 'Heavy', color: '#803030', health: 120, speed: 0.9, points: 250, weapon: 'machinegun', skill: 5, texture: 'assets/sprites/enemy_heavy.png', frame: 0 }, + { type: 'scout', name: 'Scout', color: '#d06060', health: 25, speed: 2.6, points: 150, weapon: 'pistol', skill: 3, texture: 'assets/sprites/enemy_scout.png', frame: 0 }, ], }; @@ -82,6 +99,31 @@ const DEFAULT_PICKUPS = { { id: 'treasure', name: 'Treasure', color: '#c0a020', effect: 'score', amount: 500, texture: 'assets/sprites/pickups.png', frame: 2 }, { id: 'key_gold', name: 'Gold Key', color: '#ffd700', effect: 'key', key: 'gold', texture: 'assets/sprites/pickups.png', frame: 3 }, { id: 'key_silver', name: 'Silver Key', color: '#c8c8d8', effect: 'key', key: 'silver', texture: 'assets/sprites/pickups.png', frame: 4 }, + { id: 'ammo_mg', name: 'MG Ammo', color: '#ffb020', effect: 'ammo', weapon: 'machinegun', amount: 30, texture: 'assets/sprites/pickups.png', frame: 5 }, + { id: 'ammo_gatling', name: 'Gatling Ammo', color: '#ff8020', effect: 'ammo', weapon: 'gatling', amount: 60, texture: 'assets/sprites/pickups.png', frame: 6 }, + { id: 'ammo_plasma', name: 'Plasma Cells', color: '#40ffcc', effect: 'ammo', weapon: 'plasma', amount: 15, texture: 'assets/sprites/pickups.png', frame: 7 }, + { id: 'gun_machinegun', name: 'Machine Gun', color: '#c08030', effect: 'weapon', weapon: 'machinegun', texture: 'assets/sprites/pickups.png', frame: 8 }, + { id: 'gun_gatling', name: 'Gatling Gun', color: '#c05020', effect: 'weapon', weapon: 'gatling', texture: 'assets/sprites/pickups.png', frame: 9 }, + { id: 'gun_plasma', name: 'Plasma Gun', color: '#20c0a0', effect: 'weapon', weapon: 'plasma', texture: 'assets/sprites/pickups.png', frame: 10 }, + { id: 'ammo_rocket', name: 'Rockets', color: '#ff6030', effect: 'ammo', weapon: 'rocket', amount: 5, texture: 'assets/sprites/pickups.png', frame: 11 }, + { id: 'gun_rocket', name: 'Rocket Launcher', color: '#c04020', effect: 'weapon', weapon: 'rocket', texture: 'assets/sprites/pickups.png', frame: 12 }, + { id: 'ammo_shells', name: 'Shells', color: '#ffc860', effect: 'ammo', weapon: 'shotgun', amount: 8, texture: 'assets/sprites/pickups.png', frame: 13 }, + { id: 'gun_shotgun', name: 'Shotgun', color: '#b09050', effect: 'weapon', weapon: 'shotgun', texture: 'assets/sprites/pickups.png', frame: 14 }, + { id: 'gun_doubleshotgun', name: 'Double Shotgun', color: '#907040', effect: 'weapon', weapon: 'doubleshotgun', texture: 'assets/sprites/pickups.png', frame: 15 }, + ], +}; + +const DEFAULT_WEAPONS = { + defaultWeapon: 'pistol', + weapons: [ + { id: 'melee', name: 'Knife', slot: 1, damage: 50, cooldownMs: 400, spreadDeg: 0, bulletSpeed: null, range: 1.2, ammoCost: 0, startAmmo: 0, pickupAmmo: 0, projectile: null, hudImage: 'assets/gun1.png' }, + { id: 'pistol', name: 'Pistol', slot: 2, damage: 25, cooldownMs: 450, spreadDeg: 0.8, bulletSpeed: 20, range: 20, ammoCost: 1, startAmmo: 24, pickupAmmo: 12, projectile: { color: '#ffd040', size: 0.08 }, hudImage: 'assets/gun2.png' }, + { id: 'machinegun', name: 'Machine Gun', slot: 3, damage: 12, cooldownMs: 120, spreadDeg: 3, bulletSpeed: 18, range: 18, ammoCost: 1, startAmmo: 0, pickupAmmo: 40, projectile: { color: '#ffb020', size: 0.08 }, hudImage: 'assets/gun3.png' }, + { id: 'gatling', name: 'Gatling Gun', slot: 4, damage: 10, cooldownMs: 60, spreadDeg: 6, bulletSpeed: 18, range: 16, ammoCost: 1, startAmmo: 0, pickupAmmo: 80, projectile: { color: '#ff8020', size: 0.07 }, hudImage: 'assets/gun4.png' }, + { id: 'plasma', name: 'Plasma Gun', slot: 5, damage: 60, cooldownMs: 700, spreadDeg: 1, bulletSpeed: 8, range: 24, ammoCost: 2, startAmmo: 0, pickupAmmo: 20, projectile: { color: '#40ffcc', size: 0.18 }, hudImage: 'assets/gun5.png' }, + { id: 'rocket', name: 'Rocket Launcher', slot: 6, damage: 100, cooldownMs: 1000, spreadDeg: 0.3, bulletSpeed: 6, range: 24, ammoCost: 1, startAmmo: 0, pickupAmmo: 5, projectile: { color: '#ff6030', size: 0.22 }, explosion: { radius: 2, damage: 80 }, hudImage: 'assets/gun6.png' }, + { id: 'shotgun', name: 'Shotgun', slot: 7, damage: 8, pellets: 8, cooldownMs: 900, spreadDeg: 5, bulletSpeed: 18, range: 10, ammoCost: 1, startAmmo: 0, pickupAmmo: 8, projectile: { color: '#ffc860', size: 0.055 }, hudImage: 'assets/gun7.png' }, + { id: 'doubleshotgun', name: 'Double Shotgun', slot: 8, damage: 8, pellets: 16, cooldownMs: 1500, spreadDeg: 11, bulletSpeed: 18, range: 9, ammoCost: 2, ammoId: 'shotgun', startAmmo: 0, pickupAmmo: 8, projectile: { color: '#ffc860', size: 0.055 }, hudImage: 'assets/gun8.png' }, ], }; @@ -103,7 +145,9 @@ const reg = { items: new Map(), // id (string) -> item def enemies: new Map(), // type (string) -> enemy def pickups: new Map(), // type (string) -> pickup def + weapons: new Map(), // id (string) -> weapon def defaultEnemyType: 'guard', + defaultWeaponId: 'pistol', campaigns: [], }; @@ -179,6 +223,12 @@ function applyEnemies(data) { health: typeof e.health === 'number' ? e.health : 50, speed: typeof e.speed === 'number' ? e.speed : 1.5, points: typeof e.points === 'number' ? e.points : 100, + // Weapon id (matches weapons.json) this enemy shoots with; null = unarmed + // (patrol-only, the pre-combat behavior). + weapon: typeof e.weapon === 'string' ? e.weapon : null, + // Marksmanship 1 (poor) … 10 (perfect): scales aim spread, reaction + // time, and fire cadence — see the skill helpers in Projectile.js. + skill: Math.min(10, Math.max(1, Math.round(typeof e.skill === 'number' ? e.skill : 5))), texture: e.texture ?? null, frame: e.frame ?? 0, isEnemy: true, @@ -211,9 +261,11 @@ function applyPickups(data) { type: p.id, name: p.name ?? p.id, color: parseColor(p.color), - effect: p.effect ?? null, // 'ammo' | 'health' | 'score' | 'key' + effect: p.effect ?? null, // 'ammo' | 'health' | 'score' | 'key' | 'weapon' amount: typeof p.amount === 'number' ? p.amount : 0, key: typeof p.key === 'string' ? p.key : null, + // Weapon id this pickup feeds ('ammo' effect) or grants ('weapon' effect). + weapon: typeof p.weapon === 'string' ? p.weapon : null, scale: typeof p.scale === 'number' ? p.scale : 0.5, texture: p.texture ?? null, frame: p.frame ?? 0, @@ -223,6 +275,53 @@ function applyPickups(data) { } } +function applyWeapons(data) { + reg.weapons.clear(); + for (const w of data.weapons ?? []) { + if (typeof w.id !== 'string' || !w.id) continue; + const bulletSpeed = typeof w.bulletSpeed === 'number' ? w.bulletSpeed : null; + reg.weapons.set(w.id, { + id: w.id, + name: w.name ?? w.id, + slot: Number.isInteger(w.slot) ? w.slot : 0, + // Per pellet when pellets > 1 (shotguns fire a cloud of projectiles, + // each rolling its own spread — damage falls off naturally with range + // as pellets start missing). + damage: typeof w.damage === 'number' ? w.damage : 25, + pellets: Number.isInteger(w.pellets) && w.pellets > 0 ? w.pellets : 1, + cooldownMs: typeof w.cooldownMs === 'number' ? w.cooldownMs : 400, + // Max angular deviation per shot, degrees (0 = perfectly straight). + spreadDeg: typeof w.spreadDeg === 'number' ? w.spreadDeg : 0, + bulletSpeed, // cells/sec; null = melee (no projectile) + melee: bulletSpeed === null, + range: typeof w.range === 'number' ? w.range : 15, + ammoCost: typeof w.ammoCost === 'number' ? w.ammoCost : 1, + // Ammo pool this weapon draws from; defaults to its own id. Both + // shotguns share the 'shotgun' shell pool. + ammoId: typeof w.ammoId === 'string' ? w.ammoId : w.id, + startAmmo: typeof w.startAmmo === 'number' ? w.startAmmo : 0, + pickupAmmo: typeof w.pickupAmmo === 'number' ? w.pickupAmmo : 0, + projectile: w.projectile + ? { color: parseColor(w.projectile.color, 0xffd040), size: typeof w.projectile.size === 'number' ? w.projectile.size : 0.08 } + : null, + // Splash on impact: radius in cells, damage at the blast center falling + // off linearly to zero at the edge. null = shots stop without exploding. + explosion: w.explosion && typeof w.explosion.radius === 'number' + ? { + radius: w.explosion.radius, + damage: typeof w.explosion.damage === 'number' ? w.explosion.damage : 50, + } + : null, + hudImage: typeof w.hudImage === 'string' ? w.hudImage : null, + }); + } + if (typeof data.defaultWeapon === 'string' && reg.weapons.has(data.defaultWeapon)) { + reg.defaultWeaponId = data.defaultWeapon; + } else { + reg.defaultWeaponId = reg.weapons.keys().next().value ?? 'pistol'; + } +} + // Populate from the embedded defaults immediately so accessors are safe even // before (or without) initRegistry() — e.g. level migration during import. applyWalls(DEFAULT_WALLS); @@ -231,6 +330,7 @@ applyItems(DEFAULT_ITEMS); applyEnemies(DEFAULT_ENEMIES); applyCampaigns(DEFAULT_CAMPAIGNS); applyPickups(DEFAULT_PICKUPS); +applyWeapons(DEFAULT_WEAPONS); // --------------------------------------------------------------------------- // Loading @@ -267,13 +367,14 @@ export function initRegistry() { } async function doInit() { - const [walls, overlays, items, enemies, campaigns, pickups] = await Promise.all([ + const [walls, overlays, items, enemies, campaigns, pickups, weapons] = await Promise.all([ fetchConfig('walls.json', DEFAULT_WALLS), fetchConfig('overlays.json', DEFAULT_OVERLAYS), fetchConfig('items.json', DEFAULT_ITEMS), fetchConfig('enemies.json', DEFAULT_ENEMIES), fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS), fetchConfig('pickups.json', DEFAULT_PICKUPS), + fetchConfig('weapons.json', DEFAULT_WEAPONS), ]); applyWalls(walls); applyOverlays(overlays); @@ -281,6 +382,7 @@ async function doInit() { applyEnemies(enemies); applyCampaigns(campaigns); applyPickups(pickups); + applyWeapons(weapons); // Load every referenced spritesheet once. const defs = [ @@ -312,15 +414,28 @@ async function doInit() { def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) ?? makeSpritePlaceholder(def, 'item'); } + // Enemies bake one canvas per animation frame (sheet strip when available, + // else pose-specific placeholders so the state machine is visible art-less). for (const def of reg.enemies.values()) { - def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) - ?? makeSpritePlaceholder(def, 'enemy'); - def.texFlash = makeFlashVariant(def.tex); + const sheet = images.get(def.texture); + def.frames = {}; + ENEMY_FRAME_ORDER.forEach((pose, i) => { + def.frames[pose] = bakeFrame(sheet, def.frame + i, TEX_SIZE) + ?? makeEnemyPlaceholder(def, pose); + }); + def.tex = def.frames.front; // static thumbnail (editor, fallbacks) + def.texFlash = makeFlashVariant(def.frames.pain); } for (const def of reg.pickups.values()) { def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) ?? makeSpritePlaceholder(def, def.effect === 'key' ? 'key' : 'pickup'); } + // In-flight bullet billboards (weapon HUD art is screen-space and loaded by + // the HUD scene, not baked here), plus the blast fireball for explosives. + for (const def of reg.weapons.values()) { + if (def.projectile) def.projTex = makeProjectileTex(def.projectile); + if (def.explosion) def.explosionTex = makeExplosionTex(); + } } // --------------------------------------------------------------------------- @@ -420,24 +535,98 @@ function makeOverlayPlaceholder(def) { return c; } -/** Transparent-background billboard placeholder for items/enemies/pickups. */ +/** Transparent-background enemy placeholder: the humanoid figure varied per + * animation pose (see ENEMY_FRAME_ORDER) so every sprite state reads at a + * glance without real art — walk frames spread the legs, profiles get a nose + * wedge, aim/fire raise a gun at the camera, dying tilts, dead lies down. */ +function makeEnemyPlaceholder(def, pose) { + const [c, ctx] = makeCanvas(); + const S = TEX_SIZE; + ctx.fillStyle = shade(def.color, 1); + ctx.strokeStyle = shade(def.color, 0.5); + ctx.lineWidth = Math.max(2, S / 32); + + if (pose === 'dead') { + // Corpse lying along the floor at the bottom of the frame. + ctx.beginPath(); + ctx.arc(S * 0.22, S * 0.88, S * 0.09, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillRect(S * 0.32, S * 0.84, S * 0.5, S * 0.1); + ctx.strokeRect(S * 0.32, S * 0.84, S * 0.5, S * 0.1); + return c; + } + if (pose === 'dying') { + // Keeling over: the standing figure tilted about its feet. + ctx.translate(S / 2, S * 0.98); + ctx.rotate(Math.PI / 6); + ctx.translate(-S / 2, -S * 0.98); + } else if (pose === 'pain') { + // Flinch: a slight stagger tilt (the white hurt tint goes on top). + ctx.translate(S / 2, S * 0.98); + ctx.rotate(-Math.PI / 24); + ctx.translate(-S / 2, -S * 0.98); + } + + const walk = pose.endsWith('Walk'); + const side = pose.startsWith('left') ? -1 : pose.startsWith('right') ? 1 : 0; + + // Head. + ctx.beginPath(); + ctx.arc(S / 2, S * 0.2, S * 0.12, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + // Torso (narrower in profile) and legs (spread apart on walk frames). + const tw = side ? 0.22 : 0.32; + ctx.fillRect(S * (0.5 - tw / 2), S * 0.32, S * tw, S * 0.36); + ctx.strokeRect(S * (0.5 - tw / 2), S * 0.32, S * tw, S * 0.36); + const spread = walk ? 0.14 : 0.04; + ctx.fillRect(S * (0.5 - spread - 0.1), S * 0.68, S * 0.1, S * 0.3); + ctx.fillRect(S * (0.5 + spread), S * 0.68, S * 0.1, S * 0.3); + + // Face marks: two eyes toward the camera, one eye + nose wedge in profile, + // a blank head from behind. + ctx.fillStyle = shade(def.color, 0.25); + if (side !== 0) { + ctx.beginPath(); + ctx.arc(S * (0.5 + side * 0.05), S * 0.18, S * 0.02, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(S * (0.5 + side * 0.11), S * 0.16); + ctx.lineTo(S * (0.5 + side * 0.18), S * 0.21); + ctx.lineTo(S * (0.5 + side * 0.11), S * 0.26); + ctx.closePath(); + ctx.fill(); + } else if (!pose.startsWith('back')) { + for (const ex of [0.45, 0.55]) { + ctx.beginPath(); + ctx.arc(S * ex, S * 0.18, S * 0.02, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Gun raised at the camera on aim/fire, with a muzzle flash when firing. + if (pose === 'aim' || pose === 'fire') { + ctx.fillStyle = shade(def.color, 0.3); + ctx.fillRect(S * 0.44, S * 0.4, S * 0.12, S * 0.12); + if (pose === 'fire') { + ctx.fillStyle = '#fff0a0'; + ctx.beginPath(); + ctx.arc(S / 2, S * 0.46, S * 0.09, 0, Math.PI * 2); + ctx.fill(); + } + } + return c; +} + +/** Transparent-background billboard placeholder for items/pickups. */ function makeSpritePlaceholder(def, kind) { const [c, ctx] = makeCanvas(); const S = TEX_SIZE; ctx.fillStyle = shade(def.color, 1); ctx.strokeStyle = shade(def.color, 0.5); ctx.lineWidth = Math.max(2, S / 32); - if (kind === 'enemy') { - // Simple humanoid: head + torso + legs. - ctx.beginPath(); - ctx.arc(S / 2, S * 0.2, S * 0.12, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.fillRect(S * 0.34, S * 0.32, S * 0.32, S * 0.36); - ctx.strokeRect(S * 0.34, S * 0.32, S * 0.32, S * 0.36); - ctx.fillRect(S * 0.36, S * 0.68, S * 0.1, S * 0.3); - ctx.fillRect(S * 0.54, S * 0.68, S * 0.1, S * 0.3); - } else if (kind === 'item') { + if (kind === 'item') { // Blob on a base — reads as "prop". ctx.beginPath(); ctx.ellipse(S / 2, S * 0.42, S * 0.26, S * 0.3, 0, 0, Math.PI * 2); @@ -478,6 +667,37 @@ function makeSpritePlaceholder(def, kind) { return c; } +/** Transparent-background glowing bolt for in-flight bullets: a bright core + * with a soft halo in the weapon's projectile color. */ +function makeProjectileTex(projectile) { + const [c, ctx] = makeCanvas(); + const S = TEX_SIZE; + const grad = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); + const rgb = `${(projectile.color >> 16) & 0xff},${(projectile.color >> 8) & 0xff},${projectile.color & 0xff}`; + grad.addColorStop(0, shade(projectile.color, 1.6)); + grad.addColorStop(0.35, shade(projectile.color, 1)); + grad.addColorStop(1, `rgba(${rgb},0)`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, S, S); + return c; +} + +/** Transparent-background fireball for explosion effects: a white-hot core + * through yellow and orange out to a soft transparent-red rim. */ +function makeExplosionTex() { + const [c, ctx] = makeCanvas(); + const S = TEX_SIZE; + const grad = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); + grad.addColorStop(0, '#fff8d0'); + grad.addColorStop(0.25, '#ffd040'); + grad.addColorStop(0.55, '#ff7020'); + grad.addColorStop(0.8, 'rgba(200,40,10,0.6)'); + grad.addColorStop(1, 'rgba(120,20,0,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, S, S); + return c; +} + /** White-tinted copy of a texture, used as the enemy hurt flash. */ function makeFlashVariant(tex) { const [c, ctx] = makeCanvas(); @@ -557,6 +777,20 @@ export function listPickups() { return [...reg.pickups.values()]; } +export function getWeaponDef(id) { + return reg.weapons.get(id) ?? null; +} + +/** The weapon the player holds at the start of a fresh run. */ +export function getDefaultWeaponId() { + return reg.defaultWeaponId; +} + +/** All weapon defs in number-key order (slot 1, 2, …). */ +export function listWeapons() { + return [...reg.weapons.values()].sort((a, b) => a.slot - b.slot); +} + export function getCampaigns() { return reg.campaigns; } diff --git a/src/game/scenes/GameScene.js b/src/game/scenes/GameScene.js index d9ca797..9b20236 100644 --- a/src/game/scenes/GameScene.js +++ b/src/game/scenes/GameScene.js @@ -2,30 +2,36 @@ import Phaser from 'phaser'; import { GameMap } from '../../engine/GameMap.js'; import { Player } from '../../engine/Player.js'; import { Entity } from '../../engine/Entity.js'; +import { Projectile } from '../../engine/Projectile.js'; import { Raycaster } from '../../engine/Raycaster.js'; import { SpriteRenderer } from '../../engine/SpriteRenderer.js'; import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js'; -import { getItemDef, getKeyDef } from '../../engine/registry.js'; +import { getItemDef, getKeyDef, getWeaponDef, listWeapons } from '../../engine/registry.js'; import { RENDER_WIDTH, RENDER_HEIGHT, ROT_SPEED, MOUSE_SENSITIVITY, - WEAPON_FIRE_COOLDOWN, + START_HEALTH, + DEATH_RESTART_MS, } from '../../engine/constants.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../main.js'; const VIEW_TEXTURE = 'view'; +/** Blast fireball lifetime (ms): it expands to the blast diameter and fades. */ +const EXPLOSION_MS = 350; + /** - * The playable scene. Owns the world (map + player + entities), renders the - * raycast view into a CanvasTexture each frame, and drives input. The HUD lives - * in a separate parallel scene that reads `player` from here. + * The playable scene. Owns the world (map + player + entities + projectiles), + * renders the raycast view into a CanvasTexture each frame, and drives input. + * The HUD lives in a separate parallel scene that reads `player` from here. */ export class GameScene extends Phaser.Scene { constructor() { super('GameScene'); this.entities = []; + this.projectiles = []; this.yawDelta = 0; // accumulated mouse-look for the current frame this.lastShot = 0; } @@ -36,6 +42,9 @@ export class GameScene extends Phaser.Scene { this.map = new GameMap(level); this.player = new Player(level.playerStart); this.entities = level.entities.map((s) => new Entity(s)); + this.projectiles = []; + this.effects = []; // transient billboards (explosion fireballs) + this.playerDead = false; // Decorative item props (plants, lamps, …): rendered as billboards, never // shootable or collectable; blocking ones are handled by GameMap.isBlocked. @@ -48,12 +57,22 @@ export class GameScene extends Phaser.Scene { return [{ x: s.x, y: s.y, def, active: true, isEnemy: false }]; }); - // Carry health/ammo/score forward from the previous level, if any. + // Carry health/weapons/ammo/score forward from the previous level, if any. const carry = this.registry.get('carryStats'); if (carry) { this.player.health = carry.health; - this.player.ammo = carry.ammo; this.player.score = carry.score; + if (carry.weapons) this.player.weapons = new Set(carry.weapons); + if (carry.ammo) this.player.ammo = { ...carry.ammo }; + } + // Score to fall back to if the player dies in this level. + this.entryScore = this.player.score; + + // A death restart replays the same level; tell the (persistent) HUD to + // drop its "YOU DIED" overlay. + if (this.registry.get('justDied')) { + this.registry.remove('justDied'); + this.game.events.emit('player-respawned'); } // Completion tracking: a level ends when the player reaches an exit, or — @@ -82,18 +101,32 @@ export class GameScene extends Phaser.Scene { this.sprites = new SpriteRenderer(this.ctx); this.setupInput(); + + // Sync the HUD's weapon display after every (re)start. + this.game.events.emit('weapon-changed', this.player.weapon); } setupInput() { const kb = this.input.keyboard; - this.keys = kb.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE,E'); + this.keys = kb.addKeys( + 'W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE,E,ONE,TWO,THREE,FOUR,FIVE,SIX,SEVEN,EIGHT', + ); + // Number key -> weapon def, from the data-driven slot assignments. + const k = this.keys; + this.slotKeys = new Map( + listWeapons() + .map((w) => [ + [null, k.ONE, k.TWO, k.THREE, k.FOUR, k.FIVE, k.SIX, k.SEVEN, k.EIGHT][w.slot], + w, + ]) + .filter(([key]) => key), + ); - // Click to capture the mouse; once captured, clicks fire. + // Click captures the mouse; firing while locked is handled in update() + // (held button = automatic fire, gated by the weapon's cooldown). this.input.on('pointerdown', () => { if (!this.input.mouse?.locked) { this.input.mouse?.requestPointerLock(); - } else { - this.fire(); } }); @@ -123,18 +156,48 @@ export class GameScene extends Phaser.Scene { } fire() { - if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return; + const weapon = this.player.weapon; + if (this.time.now - this.lastShot < weapon.cooldownMs) return; this.lastShot = this.time.now; - const result = this.player.shoot(this.map, this.entities); - if (!result.fired) { + + if (weapon.melee) { + const result = this.player.melee(this.map, this.entities); + if (result.killed) this.creditKill(result.hit); + this.game.events.emit('player-fired', result); + return; + } + + if (!this.player.tryConsumeAmmo()) { this.game.events.emit('player-empty'); return; } - if (result.killed) this.player.score += result.hit.def.points ?? 100; - this.game.events.emit('player-fired', result); + const { cam } = this.player; + // Shotguns fire a cloud of pellets per shell; each rolls its own spread. + for (let i = 0; i < weapon.pellets; i++) { + this.projectiles.push( + new Projectile({ + x: cam.posX, + y: cam.posY, + dirX: cam.dirX, + dirY: cam.dirY, + weapon, + owner: this.player, + }), + ); + } + this.game.events.emit('player-fired', { fired: true, hit: null, killed: false }); + } + + /** Score + HUD message for an enemy killed by the player (melee or bullet). */ + creditKill(enemy) { + this.player.score += enemy.def.points ?? 100; + this.game.events.emit('enemy-killed', enemy.def); } update(_time, deltaMs) { + // Dead: freeze the world (still drawn) until the delayed restart fires. + if (this.playerDead) return; + const dt = deltaMs / 1000; // --- Build movement intent from keyboard + accumulated mouse look. --- @@ -155,8 +218,24 @@ export class GameScene extends Phaser.Scene { this.player.update(this.map, input, dt); - // Keyboard fire fallback (Space), useful before grabbing the mouse. - if (k.SPACE.isDown) this.fire(); + // Number keys switch to an owned weapon; unowned guns flash a message. + for (const [key, def] of this.slotKeys) { + if (!Phaser.Input.Keyboard.JustDown(key)) continue; + if (this.player.selectWeapon(def.id)) { + this.game.events.emit('weapon-changed', def); + } else { + this.game.events.emit('weapon-locked', def); + } + } + + // Hold-to-fire: Space, or the mouse button while the pointer is locked. + // Per-weapon cooldowns turn this into the right semi/full-auto cadence. + if ( + k.SPACE.isDown || + (this.input.mouse?.locked && this.input.activePointer.isDown) + ) { + this.fire(); + } // E uses the door ahead (open, or close an open one). if (Phaser.Input.Keyboard.JustDown(k.E)) this.tryUseDoor(); @@ -167,14 +246,37 @@ export class GameScene extends Phaser.Scene { y: Math.floor(cam.posY), }); - for (const e of this.entities) e.update(deltaMs); + const world = { + map: this.map, + player: this.player, + entities: this.entities, + spawnProjectile: (opts) => this.projectiles.push(new Projectile(opts)), + }; + for (const e of this.entities) e.update(deltaMs, world); + + // --- Fly the bullets and apply what they hit. A shot reports its results + // --- in bulk: an explosion can kill several enemies and hurt the player + // --- (splash ignores ownership) in the same frame. + for (const p of this.projectiles) { + p.update(dt, world); + for (const e of p.kills) this.creditKill(e); + p.kills.length = 0; + if (p.playerDamage > 0) this.damagePlayer(p.playerDamage); + if (p.exploded) this.spawnExplosion(p.exploded, p.explosionTex); + } + if (this.projectiles.some((p) => !p.active)) { + this.projectiles = this.projectiles.filter((p) => p.active); + } + + this.updateEffects(deltaMs); + this.handlePickups(); // No-exit levels finish when the last guard falls. if ( !this.hasExit && this.hadEnemies && - !this.entities.some((e) => e.isEnemy && e.active) + !this.entities.some((e) => e.alive) ) { this.completeLevel(); } @@ -183,12 +285,67 @@ export class GameScene extends Phaser.Scene { this.raycaster.render(this.map, this.player.cam); this.sprites.render( this.player.cam, - [...this.entities, ...this.props], + [...this.entities, ...this.props, ...this.projectiles, ...this.effects], this.raycaster.zBuffer, ); this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU } + /** Kick off a blast fireball billboard where an explosive shot detonated. */ + spawnExplosion({ x, y, radius }, tex) { + if (!tex) return; // registry not baked (tests) + this.effects.push({ + x, + y, + active: true, + isEnemy: false, + ttl: EXPLOSION_MS, + // Grows in updateEffects() up to the blast diameter (scale is in + // wall-heights, i.e. cells) so the fireball shows the true splash reach. + maxScale: radius * 2, + alpha: 1, + def: { tex, scale: 0.4, anchor: 'center' }, + }); + } + + /** Expand + fade the transient effect billboards, dropping finished ones. */ + updateEffects(deltaMs) { + for (const fx of this.effects) { + fx.ttl -= deltaMs; + const t = Math.min(1, 1 - fx.ttl / EXPLOSION_MS); // 0 spawn → 1 done + fx.def.scale = 0.4 + (fx.maxScale - 0.4) * t; + fx.alpha = 1 - t; + if (fx.ttl <= 0) fx.active = false; + } + if (this.effects.some((fx) => !fx.active)) { + this.effects = this.effects.filter((fx) => fx.active); + } + } + + /** Enemy fire connected: hurt the player, and handle death. */ + damagePlayer(amount) { + if (this.playerDead) return; + const died = this.player.takeDamage(amount); + this.game.events.emit('player-hurt', this.player.health); + if (!died) return; + + // Death: freeze the scene, show the overlay (HUD), then replay this level + // with the fresh starting loadout — only the score entering the level + // survives the attempt. + this.playerDead = true; + this.game.events.emit('player-died'); + this.time.delayedCall(DEATH_RESTART_MS, () => { + this.registry.set('carryStats', { + health: START_HEALTH, + score: this.entryScore, + weapons: null, // Player's constructor defaults: melee + pistol + ammo: null, + }); + this.registry.set('justDied', true); + this.scene.restart(); + }); + } + /** Walk-over pickups: collect nearby active items; reaching an exit ends the * level. */ handlePickups() { @@ -223,11 +380,12 @@ export class GameScene extends Phaser.Scene { return; } - // Carry health/ammo/score into the next level for a real campaign feel. + // Carry health/weapons/ammo/score into the next level for a real campaign feel. this.registry.set('carryStats', { health: this.player.health, - ammo: this.player.ammo, score: this.player.score, + weapons: [...this.player.weapons], + ammo: { ...this.player.ammo }, }); this.game.events.emit('level-complete'); @@ -242,7 +400,7 @@ export class GameScene extends Phaser.Scene { const def = e.def; switch (def.effect) { case 'ammo': - this.player.ammo += def.amount ?? 10; + this.player.addAmmo(def.weapon ?? 'pistol', def.amount ?? 10); break; case 'health': this.player.health = Math.min(100, this.player.health + (def.amount ?? 25)); @@ -253,6 +411,13 @@ export class GameScene extends Phaser.Scene { case 'key': if (def.key) this.player.keys.add(def.key); break; + case 'weapon': + if (def.weapon && this.player.addWeapon(def.weapon)) { + // Classic FPS feel: switch to the new gun right away. + this.player.selectWeapon(def.weapon); + this.game.events.emit('weapon-changed', getWeaponDef(def.weapon)); + } + break; } } } diff --git a/src/game/scenes/HudScene.js b/src/game/scenes/HudScene.js index 5bfae10..d1af1d3 100644 --- a/src/game/scenes/HudScene.js +++ b/src/game/scenes/HudScene.js @@ -1,11 +1,12 @@ import Phaser from 'phaser'; -import { listPickups } from '../../engine/registry.js'; +import { listPickups, listWeapons } from '../../engine/registry.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../main.js'; /** - * Overlay HUD: health / ammo / score readout, a crosshair, a stub weapon at the - * bottom of the screen with a muzzle flash on fire, and a transient message - * line. Runs in parallel with GameScene and reads its `player` each frame. + * Overlay HUD: health / ammo / score readout, a crosshair, the current + * weapon's first-person viewmodel with a muzzle flash on fire, a red flash + * when the player is hit, a "YOU DIED" overlay, and a transient message line. + * Runs in parallel with GameScene and reads its `player` each frame. */ export class HudScene extends Phaser.Scene { constructor() { @@ -13,6 +14,14 @@ export class HudScene extends Phaser.Scene { this.messageTimer = 0; } + /** Queue the weapon viewmodel PNGs (assets/gunN.png). Safe to read the + * registry here: BootScene awaits initRegistry() before launching us. */ + preload() { + for (const w of listWeapons()) { + if (w.hudImage) this.load.image(`weapon-${w.id}`, w.hudImage); + } + } + create() { this.gameScene = this.scene.get('GameScene'); @@ -44,14 +53,25 @@ export class HudScene extends Phaser.Scene { this.add.rectangle(cx, cy, 2, 14, 0xffffff, 0.7); this.add.rectangle(cx, cy, 14, 2, 0xffffff, 0.7); - // Stub weapon + muzzle flash anchored to the bottom-center. + // First-person weapon viewmodel (1920x1080 art, shown 960x540) anchored + // to the bottom edge; the texture tracks the player's current weapon in + // update(). Muzzle flash sits roughly at barrel height. + this.weaponTexId = null; this.weapon = this.add - .rectangle(cx, GAME_HEIGHT - 50, 70, 110, 0x444444) - .setStrokeStyle(2, 0x222222); + .image(cx, GAME_HEIGHT, '__DEFAULT') + .setOrigin(0.5, 1) + .setVisible(false); this.muzzle = this.add - .ellipse(cx, GAME_HEIGHT - 110, 40, 30, 0xffd040) + .ellipse(cx, GAME_HEIGHT - 240, 40, 30, 0xffd040) .setAlpha(0); + // Red full-screen flash when the player takes a hit. + this.hurtFlash = this.add + .rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0xff0000, 1) + .setDepth(5) + .setAlpha(0); + + this.deathObjects = []; // "YOU DIED" overlay pieces, removed on respawn this.victoryShown = false; // scene instances persist; reset per run // Event wiring from the game. @@ -59,6 +79,12 @@ export class HudScene extends Phaser.Scene { 'player-fired': (result) => this.onFired(result), 'player-empty': () => this.flash('*click* out of ammo'), 'player-pickup': (name) => this.flash(`Picked up ${name}`), + 'weapon-changed': (def) => this.flash(def.name), + 'weapon-locked': (def) => this.flash(`You don't have the ${def.name}`), + 'enemy-killed': (def) => this.flash(`${def.name} down! +${def.points ?? 100}`), + 'player-hurt': () => this.hurtFlash.setAlpha(0.35), + 'player-died': () => this.showDeath(), + 'player-respawned': () => this.clearDeath(), 'door-locked': (keyName) => this.flash(`Locked — you need the ${keyName}`), 'level-complete': () => this.flash('Level complete!'), 'game-won': (score) => this.showVictory(score), @@ -73,6 +99,29 @@ export class HudScene extends Phaser.Scene { }); } + /** Dark overlay + red "YOU DIED" while GameScene waits to restart. */ + showDeath() { + this.clearDeath(); + this.deathObjects = [ + this.add + .rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) + .setDepth(10), + this.add + .text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20, 'YOU DIED', { + fontFamily: 'monospace', + fontSize: '48px', + color: '#ff3030', + }) + .setOrigin(0.5) + .setDepth(11), + ]; + } + + clearDeath() { + for (const obj of this.deathObjects) obj.destroy(); + this.deathObjects = []; + } + /** Full-screen "you escaped" overlay shown after the final level. */ showVictory(score) { if (this.victoryShown) return; // guard against duplicate game-won events @@ -126,13 +175,9 @@ export class HudScene extends Phaser.Scene { } onFired(result) { - // Muzzle flash + a tiny recoil kick on the weapon. + if (result.melee) return; // no muzzle flash / recoil on a knife swing this.muzzle.setAlpha(1); this.weapon.y += 6; - if (result.killed) { - const def = result.hit.def; - this.flash(`${def.name} down! +${def.points ?? 100}`); - } } flash(msg) { @@ -143,10 +188,27 @@ export class HudScene extends Phaser.Scene { update(_time, deltaMs) { const p = this.gameScene?.player; if (p) { + const w = p.weapon; + const ammoLabel = w.melee ? '--' : (p.ammo[w.ammoId] ?? 0); this.statusText.setText( - `HEALTH ${p.health} AMMO ${p.ammo} SCORE ${p.score}`, + `HEALTH ${p.health} ${w.name.toUpperCase()} ${ammoLabel} SCORE ${p.score}`, ); + // Track the equipped weapon's viewmodel (covers boot, switches, and + // scene restarts regardless of event timing). Weapons without art + // (missing gunN.png) hide it rather than showing the previous gun. + if (this.weaponTexId !== w.id) { + this.weaponTexId = w.id; + if (this.textures.exists(`weapon-${w.id}`)) { + this.weapon + .setTexture(`weapon-${w.id}`) + .setDisplaySize(GAME_WIDTH, GAME_WIDTH * (1080 / 1920)) + .setVisible(true); + } else { + this.weapon.setVisible(false); + } + } + // Key chips, top-right (a few rects — cheap to redraw every frame). this.keyGfx.clear(); let i = 0; @@ -160,11 +222,14 @@ export class HudScene extends Phaser.Scene { } } - // Decay the muzzle flash and recoil. + // Decay the muzzle flash, recoil, and hurt flash. if (this.muzzle.alpha > 0) { this.muzzle.setAlpha(Math.max(0, this.muzzle.alpha - deltaMs / 80)); } - const restY = GAME_HEIGHT - 50; + if (this.hurtFlash.alpha > 0) { + this.hurtFlash.setAlpha(Math.max(0, this.hurtFlash.alpha - deltaMs / 300)); + } + const restY = GAME_HEIGHT; if (this.weapon.y > restY) { this.weapon.y = Math.max(restY, this.weapon.y - deltaMs / 8); }