feat: data-driven weapon system, projectiles, and enemy AI

Replace hitscan shooting with flying projectiles and introduce a
data-driven weapons.json supporting 8 weapons (melee through double
shotgun) with per-weapon stats for damage, spread, cooldown, ammo,
and explosions.

Enemies patrol authored waypoint loops and engage on sight using
line-of-sight checks, firing skill-based weapon shots with reaction
delays and accuracy scaling (skill 1–10). Each enemy now uses a
13-frame animation sheet (front/left/right/back, walk variants,
aim, fire, pain, dying, dead) with procedural placeholder art per
pose.

Player gains per-weapon ammo pools, number-key weapon switching,
melee combat, and death/restart with score carry-forward. HUD shows
weapon viewmodels, kill notifications, hurt flash, and "YOU DIED"
overlay. Explosive weapons spawn expanding fireball VFX.

New assets: per-enemy sprite sheets, expanded pickups sheet (16
frames), nerf gun HUD images.
This commit is contained in:
Brian Fertig 2026-07-02 18:11:10 -06:00
parent 1c3bed3beb
commit f38e6ba299
21 changed files with 1298 additions and 154 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

BIN
assets/nerf-gatlin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

BIN
assets/nerf-knife.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

BIN
assets/nerf-pistol.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

BIN
assets/nerf-plasma.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

BIN
assets/nerf-rifle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

BIN
assets/nerf-shotgun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

View File

@ -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 |
| 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

View File

@ -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
}
]
}

View File

@ -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
}
]
}

129
src/config/weapons.json Normal file
View File

@ -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"
}
]
}

View File

@ -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

View File

@ -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;

View File

@ -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 };
}
}

161
src/engine/Projectile.js Normal file
View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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);
}