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 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. 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 **13 frames → 2496×192 px each. Transparent background required** —
billboards. full-wall-height billboards. One PNG per enemy type:
| Frame | Pixels | Enemy (`enemies.json`) | Stats to suggest visually | | File | Enemy (`enemies.json`) | Stats to suggest visually |
| ----- | ------- | ---------------------- | ------------------------------------ | | -------------------------------- | ---------------------- | ------------------------------------ |
| 0 | 0–191 | `guard` | baseline: 50 hp, average speed | | `assets/sprites/enemy_guard.png` | `guard` | baseline: 50 hp, average speed |
| 1 | 192–383 | `heavy` | tanky: 120 hp, slow — bulkier build | | `assets/sprites/enemy_heavy.png` | `heavy` | tanky: 120 hp, slow — bulkier build |
| 2 | 384–575 | `scout` | fragile: 25 hp, fast — lighter 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: Notes:
- One static frame per enemy for now (no walk/attack animation support yet — - Walk frames alternate with their standing pose while the enemy patrols
when that lands, these will likely grow into multi-frame strips, which is (cadence tracks the enemy's speed); `aim`/`fire` are **front-facing only** —
why keeping each enemy's frames contiguous is a good habit). enemies turn to face the player whenever they engage.
- Enemies are drawn at full wall height and stand on the floor; like items, - Enemies are drawn at full wall height and stand on the floor; have the feet
have the feet touch the bottom edge of the frame. touch the bottom edge of the frame. The `dying`/`dead` frames live in the
- The hurt flash is generated automatically (white-tinted copy) — you don't same full-height square, so draw the corpse lying along the bottom edge.
need to draw one. - 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. `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 floor-anchored billboards (drawn at 0.5 × wall height by default; add a
`scale` field in `pickups.json` to change per pickup). `scale` field in `pickups.json` to change per pickup).
| Frame | Pixels | Pickup (`pickups.json`) | Placeholder it replaces | | Frame | Pixels | Pickup (`pickups.json`) | Placeholder it replaces |
| ----- | ------- | ------------------------ | ---------------------------- | | ----- | --------- | -------------------------------- | ----------------------- |
| 0 | 0–191 | `ammo` — Ammo | yellow diamond | | 0 | 0–191 | `ammo` — Ammo | yellow diamond |
| 1 | 192–383 | `health` — Health | green diamond | | 1 | 192–383 | `health` — Health | green diamond |
| 2 | 384–575 | `treasure` — Treasure | gold diamond | | 2 | 384–575 | `treasure` — Treasure | gold diamond |
| 3 | 576–767 | `key_gold` — Gold Key | gold key glyph | | 3 | 576–767 | `key_gold` — Gold Key | gold key glyph |
| 4 | 768–959 | `key_silver` — Silver Key | silver 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: Notes:
- Key colors should match their door and their HUD chip (the chip uses the - Key colors should match their door and their HUD chip (the chip uses the
@ -127,10 +162,11 @@ Notes:
## Adding more content later ## Adding more content later
New wall/overlay/item/enemy/pickup = one new 192×192 frame appended to the New wall/overlay/item/pickup = one new 192×192 frame appended to the relevant
relevant strip + one JSON entry in `src/config/` pointing at its `frame` strip + one JSON entry in `src/config/` pointing at its `frame` index. A new
index. Nothing else. You can also start a fresh PNG per entry if you prefer — enemy = a fresh 13-frame sheet (see section 4) + its JSON entry. Nothing
the `texture` path is per-entry, not global. else. You can also start a fresh PNG per entry if you prefer — the `texture`
path is per-entry, not global.
## Changing resolution later ## Changing resolution later

View File

@ -8,7 +8,9 @@
"health": 50, "health": 50,
"speed": 1.5, "speed": 1.5,
"points": 100, "points": 100,
"texture": "assets/sprites/enemies.png", "weapon": "pistol",
"skill": 4,
"texture": "assets/sprites/enemy_guard.png",
"frame": 0 "frame": 0
}, },
{ {
@ -18,8 +20,10 @@
"health": 120, "health": 120,
"speed": 0.9, "speed": 0.9,
"points": 250, "points": 250,
"texture": "assets/sprites/enemies.png", "weapon": "machinegun",
"frame": 1 "skill": 5,
"texture": "assets/sprites/enemy_heavy.png",
"frame": 0
}, },
{ {
"type": "scout", "type": "scout",
@ -28,8 +32,10 @@
"health": 25, "health": 25,
"speed": 2.6, "speed": 2.6,
"points": 150, "points": 150,
"texture": "assets/sprites/enemies.png", "weapon": "pistol",
"frame": 2 "skill": 3,
"texture": "assets/sprites/enemy_scout.png",
"frame": 0
} }
] ]
} }

View File

@ -5,6 +5,7 @@
"name": "Ammo", "name": "Ammo",
"color": "#d0d040", "color": "#d0d040",
"effect": "ammo", "effect": "ammo",
"weapon": "pistol",
"amount": 10, "amount": 10,
"texture": "assets/sprites/pickups.png", "texture": "assets/sprites/pickups.png",
"frame": 0 "frame": 0
@ -44,6 +45,110 @@
"key": "silver", "key": "silver",
"texture": "assets/sprites/pickups.png", "texture": "assets/sprites/pickups.png",
"frame": 4 "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 * A world entity: enemy or pickup. Enemies patrol authored waypoint loops and,
* validate sprites + shooting now. Full enemy AI (patrol/chase/attack) plugs * if their type carries a weapon (enemies.json), engage the player on sight:
* into `update()` and the `state` transitions later. * 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 { export class Entity {
constructor(spawn) { constructor(spawn) {
@ -14,9 +20,19 @@ export class Entity {
getEntityDef(spawn.type) ?? getEntityDef(spawn.type) ??
{ type: spawn.type, name: spawn.type, color: 0xff00ff, isEnemy: false, health: 0 }; { type: spawn.type, name: spawn.type, color: 0xff00ff, isEnemy: false, health: 0 };
this.health = this.def.health; this.health = this.def.health;
this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected' this.state = 'idle'; // 'idle' | 'hurt' | 'dying' | 'dead' | 'gone' | 'collected'
/** Ms remaining in a transient state (e.g. the hurt flinch flash). */ /** 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; 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 // Patrol route (enemies only): the spawn point plus the level-authored
// path points, walked in a loop at the def's speed (cells/sec). // 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 }))] ? [{ x: spawn.x, y: spawn.y }, ...path.map((p) => ({ x: p.x, y: p.y }))]
: null; : null;
this.wpIndex = 1; // current target (0 is the spawn point) 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() { get isEnemy() {
return this.def.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() { 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. */ /** Apply damage (enemies only). Returns true if this shot killed it. */
damage(amount) { damage(amount) {
if (!this.isEnemy || !this.active) return false; if (!this.alive) return false;
this.health -= amount; this.health -= amount;
if (this.health <= 0) { if (this.health <= 0) {
this.state = 'dead'; this.state = 'dying';
this.stateTimer = Entity.DYING_MS;
this.engaged = false;
return true; return true;
} }
this.state = 'hurt'; this.state = 'hurt';
@ -54,22 +104,97 @@ export class Entity {
if (!this.isEnemy) this.state = 'collected'; 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) { if (this.stateTimer > 0) {
this.stateTimer -= deltaMs; this.stateTimer -= deltaMs;
if (this.stateTimer <= 0 && this.state === 'hurt') { if (this.stateTimer <= 0 && this.state === 'hurt') {
this.state = 'idle'; this.state = 'idle';
} }
} }
this.updatePatrol(deltaMs); this.updateCombat(deltaMs, world);
// TODO: further enemy AI (chase/attack/pathfinding) hooks in here. // 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. /** Walk the authored patrol loop. Getting shot (hurt flinch) pauses it.
* Paths are author-trusted: no collision checks, so a path drawn through * Paths are author-trusted: no collision checks, so a path drawn through
* a wall will walk through it. */ * a wall will walk through it. */
updatePatrol(deltaMs) { 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 target = this.waypoints[this.wpIndex];
const step = (this.def.speed ?? 1.5) * (deltaMs / 1000); const step = (this.def.speed ?? 1.5) * (deltaMs / 1000);
const dx = target.x - this.x; const dx = target.x - this.x;
@ -83,5 +208,58 @@ export class Entity {
this.x += (dx / dist) * step; this.x += (dx / dist) * step;
this.y += (dy / 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; 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. */ /** Whether movement is blocked here — by a wall or by a blocking item. */
isBlocked(x, y) { isBlocked(x, y) {
if (this.isWall(x, y)) return true; if (this.isWall(x, y)) return true;

View File

@ -2,26 +2,40 @@ import {
FOV, FOV,
MOVE_SPEED, MOVE_SPEED,
PLAYER_RADIUS, PLAYER_RADIUS,
WEAPON_RANGE,
WEAPON_DAMAGE,
START_AMMO,
START_HEALTH, START_HEALTH,
} from './constants.js'; } from './constants.js';
import { getWeaponDef, getDefaultWeaponId, listWeapons } from './registry.js';
/** /**
* The player: a camera (position + direction/plane vectors) plus movement with * The player: a camera (position + direction/plane vectors) plus movement with
* grid collision and a simple hitscan weapon. Direction is stored as vectors so * grid collision and a weapon loadout. Direction is stored as vectors so it
* it feeds the raycaster directly with no per-frame trig in the hot loop. * 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 { export class Player {
constructor(start) { constructor(start) {
this.health = START_HEALTH; this.health = START_HEALTH;
this.ammo = START_AMMO;
this.score = 0; this.score = 0;
/** Key ids collected this level (e.g. 'gold') — opens matching doors. /** Key ids collected this level (e.g. 'gold') — opens matching doors.
* Classic raycaster rules: keys reset on every level. */ * Classic raycaster rules: keys reset on every level. */
this.keys = new Set(); 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 dirX = Math.cos(start.dir);
const dirY = Math.sin(start.dir); const dirY = Math.sin(start.dir);
// Camera plane is perpendicular to dir; its length sets the FOV. // 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. */ /** Apply rotation by `angle` radians, rotating both dir and plane vectors. */
rotate(angle) { rotate(angle) {
const c = Math.cos(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 /** Instant melee swing: hits the nearest active enemy within the knife's
* enemy that is both in range and roughly under the crosshair, and that has * short reach and a wide frontal arc (the crosshair cone would be only
* clear line of sight (no wall between). */ * ~20 cm wide at this range). Rays don't leave the room: line of sight is
shoot(map, entities) { * still required so a wall — or a closed door — blocks the swing. */
if (this.ammo <= 0) return { fired: false, hit: null, killed: false }; melee(map, entities) {
this.ammo--; const w = this.weapon;
const { cam } = this; const { cam } = this;
let best = null; let best = null;
let bestDist = WEAPON_RANGE; let bestDist = w.range;
for (const e of entities) { 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 dx = e.x - cam.posX;
const dy = e.y - cam.posY; const dy = e.y - cam.posY;
const dist = Math.hypot(dx, dy); 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); 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; best = e;
bestDist = dist; bestDist = dist;
} }
} }
if (!best) return { fired: true, hit: null, killed: false }; if (!best) return { fired: true, hit: null, killed: false, melee: true };
const killed = best.damage(WEAPON_DAMAGE); const killed = best.damage(w.damage);
return { fired: true, hit: best, killed }; return { fired: true, hit: best, killed, melee: true };
}
/** 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;
} }
} }

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. * raycaster's ZBuffer so walls correctly occlude sprites.
* *
* A renderable needs: x, y, active, isEnemy, and a def with tex (+ optional * 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 { export class SpriteRenderer {
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) { constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
@ -48,8 +50,10 @@ export class SpriteRenderer {
const spriteW = spriteH; // square billboards const spriteW = spriteH; // square billboards
if (spriteH < 1) continue; // too far away to draw if (spriteH < 1) continue; // too far away to draw
// Anchor items/pickups to the floor; center enemies vertically. // Anchor items/pickups to the floor; center enemies vertically, and
const vMove = e.isEnemy ? 0 : fullH / 2 - spriteH / 2; // 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; const centerY = this.height / 2 + vMove;
// Unclamped bounds — the canvas clips vertically, keeping the 1:1 // Unclamped bounds — the canvas clips vertically, keeping the 1:1
// mapping between source texture rows and on-screen pixels. // mapping between source texture rows and on-screen pixels.
@ -57,9 +61,17 @@ export class SpriteRenderer {
const drawStartX = Math.floor(screenX - spriteW / 2); const drawStartX = Math.floor(screenX - spriteW / 2);
const drawEndX = 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) 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. // Per-column occlusion: only draw stripes nearer than the wall there.
for (let stripe = drawStartX; stripe <= drawEndX; stripe++) { for (let stripe = drawStartX; stripe <= drawEndX; stripe++) {
if (stripe < 0 || stripe >= this.width) continue; if (stripe < 0 || stripe >= this.width) continue;
@ -74,6 +86,7 @@ export class SpriteRenderer {
stripe, drawStartY, 1, spriteH, 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 // 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; export const START_HEALTH = 100;
/** How long the "YOU DIED" overlay shows before the level restarts. */
export const DEATH_RESTART_MS = 2000;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Door tuning // 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 * 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. * 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` * 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 * — cut from the sheet when the image exists, otherwise a procedurally
* the image exists, otherwise a procedurally generated placeholder in the * generated placeholder in the definition's color. Enemies instead get a full
* definition's color. Renderers can therefore drawImage(def.tex, …) * `def.frames` map (one canvas per ENEMY_FRAME_ORDER pose, `frame` being the
* unconditionally; dropping real PNGs into assets/ requires no code changes. * 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 /** Square size (px) of all baked texture canvases (and the default source
* frame size for spritesheets — see sprites.md). */ * frame size for spritesheets — see sprites.md). */
export const TEX_SIZE = 192; 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 // Embedded fallback config
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -61,9 +78,9 @@ const DEFAULT_ITEMS = {
const DEFAULT_ENEMIES = { const DEFAULT_ENEMIES = {
defaultType: 'guard', defaultType: 'guard',
enemies: [ enemies: [
{ type: 'guard', name: 'Guard', color: '#b03030', health: 50, speed: 1.5, points: 100, texture: 'assets/sprites/enemies.png', frame: 0 }, { 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, texture: 'assets/sprites/enemies.png', frame: 1 }, { 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, texture: 'assets/sprites/enemies.png', frame: 2 }, { 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: '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_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: '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 items: new Map(), // id (string) -> item def
enemies: new Map(), // type (string) -> enemy def enemies: new Map(), // type (string) -> enemy def
pickups: new Map(), // type (string) -> pickup def pickups: new Map(), // type (string) -> pickup def
weapons: new Map(), // id (string) -> weapon def
defaultEnemyType: 'guard', defaultEnemyType: 'guard',
defaultWeaponId: 'pistol',
campaigns: [], campaigns: [],
}; };
@ -179,6 +223,12 @@ function applyEnemies(data) {
health: typeof e.health === 'number' ? e.health : 50, health: typeof e.health === 'number' ? e.health : 50,
speed: typeof e.speed === 'number' ? e.speed : 1.5, speed: typeof e.speed === 'number' ? e.speed : 1.5,
points: typeof e.points === 'number' ? e.points : 100, 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, texture: e.texture ?? null,
frame: e.frame ?? 0, frame: e.frame ?? 0,
isEnemy: true, isEnemy: true,
@ -211,9 +261,11 @@ function applyPickups(data) {
type: p.id, type: p.id,
name: p.name ?? p.id, name: p.name ?? p.id,
color: parseColor(p.color), 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, amount: typeof p.amount === 'number' ? p.amount : 0,
key: typeof p.key === 'string' ? p.key : null, 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, scale: typeof p.scale === 'number' ? p.scale : 0.5,
texture: p.texture ?? null, texture: p.texture ?? null,
frame: p.frame ?? 0, 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 // Populate from the embedded defaults immediately so accessors are safe even
// before (or without) initRegistry() — e.g. level migration during import. // before (or without) initRegistry() — e.g. level migration during import.
applyWalls(DEFAULT_WALLS); applyWalls(DEFAULT_WALLS);
@ -231,6 +330,7 @@ applyItems(DEFAULT_ITEMS);
applyEnemies(DEFAULT_ENEMIES); applyEnemies(DEFAULT_ENEMIES);
applyCampaigns(DEFAULT_CAMPAIGNS); applyCampaigns(DEFAULT_CAMPAIGNS);
applyPickups(DEFAULT_PICKUPS); applyPickups(DEFAULT_PICKUPS);
applyWeapons(DEFAULT_WEAPONS);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loading // Loading
@ -267,13 +367,14 @@ export function initRegistry() {
} }
async function doInit() { 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('walls.json', DEFAULT_WALLS),
fetchConfig('overlays.json', DEFAULT_OVERLAYS), fetchConfig('overlays.json', DEFAULT_OVERLAYS),
fetchConfig('items.json', DEFAULT_ITEMS), fetchConfig('items.json', DEFAULT_ITEMS),
fetchConfig('enemies.json', DEFAULT_ENEMIES), fetchConfig('enemies.json', DEFAULT_ENEMIES),
fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS), fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS),
fetchConfig('pickups.json', DEFAULT_PICKUPS), fetchConfig('pickups.json', DEFAULT_PICKUPS),
fetchConfig('weapons.json', DEFAULT_WEAPONS),
]); ]);
applyWalls(walls); applyWalls(walls);
applyOverlays(overlays); applyOverlays(overlays);
@ -281,6 +382,7 @@ async function doInit() {
applyEnemies(enemies); applyEnemies(enemies);
applyCampaigns(campaigns); applyCampaigns(campaigns);
applyPickups(pickups); applyPickups(pickups);
applyWeapons(weapons);
// Load every referenced spritesheet once. // Load every referenced spritesheet once.
const defs = [ const defs = [
@ -312,15 +414,28 @@ async function doInit() {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeSpritePlaceholder(def, 'item'); ?? 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()) { for (const def of reg.enemies.values()) {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) const sheet = images.get(def.texture);
?? makeSpritePlaceholder(def, 'enemy'); def.frames = {};
def.texFlash = makeFlashVariant(def.tex); 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()) { for (const def of reg.pickups.values()) {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE) def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeSpritePlaceholder(def, def.effect === 'key' ? 'key' : 'pickup'); ?? 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; 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) { function makeSpritePlaceholder(def, kind) {
const [c, ctx] = makeCanvas(); const [c, ctx] = makeCanvas();
const S = TEX_SIZE; const S = TEX_SIZE;
ctx.fillStyle = shade(def.color, 1); ctx.fillStyle = shade(def.color, 1);
ctx.strokeStyle = shade(def.color, 0.5); ctx.strokeStyle = shade(def.color, 0.5);
ctx.lineWidth = Math.max(2, S / 32); ctx.lineWidth = Math.max(2, S / 32);
if (kind === 'enemy') { if (kind === 'item') {
// 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') {
// Blob on a base — reads as "prop". // Blob on a base — reads as "prop".
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(S / 2, S * 0.42, S * 0.26, S * 0.3, 0, 0, Math.PI * 2); 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; 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. */ /** White-tinted copy of a texture, used as the enemy hurt flash. */
function makeFlashVariant(tex) { function makeFlashVariant(tex) {
const [c, ctx] = makeCanvas(); const [c, ctx] = makeCanvas();
@ -557,6 +777,20 @@ export function listPickups() {
return [...reg.pickups.values()]; 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() { export function getCampaigns() {
return reg.campaigns; return reg.campaigns;
} }

View File

@ -2,30 +2,36 @@ import Phaser from 'phaser';
import { GameMap } from '../../engine/GameMap.js'; import { GameMap } from '../../engine/GameMap.js';
import { Player } from '../../engine/Player.js'; import { Player } from '../../engine/Player.js';
import { Entity } from '../../engine/Entity.js'; import { Entity } from '../../engine/Entity.js';
import { Projectile } from '../../engine/Projectile.js';
import { Raycaster } from '../../engine/Raycaster.js'; import { Raycaster } from '../../engine/Raycaster.js';
import { SpriteRenderer } from '../../engine/SpriteRenderer.js'; import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.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 { import {
RENDER_WIDTH, RENDER_WIDTH,
RENDER_HEIGHT, RENDER_HEIGHT,
ROT_SPEED, ROT_SPEED,
MOUSE_SENSITIVITY, MOUSE_SENSITIVITY,
WEAPON_FIRE_COOLDOWN, START_HEALTH,
DEATH_RESTART_MS,
} from '../../engine/constants.js'; } from '../../engine/constants.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
const VIEW_TEXTURE = 'view'; 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 * The playable scene. Owns the world (map + player + entities + projectiles),
* raycast view into a CanvasTexture each frame, and drives input. The HUD lives * renders the raycast view into a CanvasTexture each frame, and drives input.
* in a separate parallel scene that reads `player` from here. * The HUD lives in a separate parallel scene that reads `player` from here.
*/ */
export class GameScene extends Phaser.Scene { export class GameScene extends Phaser.Scene {
constructor() { constructor() {
super('GameScene'); super('GameScene');
this.entities = []; this.entities = [];
this.projectiles = [];
this.yawDelta = 0; // accumulated mouse-look for the current frame this.yawDelta = 0; // accumulated mouse-look for the current frame
this.lastShot = 0; this.lastShot = 0;
} }
@ -36,6 +42,9 @@ export class GameScene extends Phaser.Scene {
this.map = new GameMap(level); this.map = new GameMap(level);
this.player = new Player(level.playerStart); this.player = new Player(level.playerStart);
this.entities = level.entities.map((s) => new Entity(s)); 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 // Decorative item props (plants, lamps, …): rendered as billboards, never
// shootable or collectable; blocking ones are handled by GameMap.isBlocked. // 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 }]; 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'); const carry = this.registry.get('carryStats');
if (carry) { if (carry) {
this.player.health = carry.health; this.player.health = carry.health;
this.player.ammo = carry.ammo;
this.player.score = carry.score; 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 — // 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.sprites = new SpriteRenderer(this.ctx);
this.setupInput(); this.setupInput();
// Sync the HUD's weapon display after every (re)start.
this.game.events.emit('weapon-changed', this.player.weapon);
} }
setupInput() { setupInput() {
const kb = this.input.keyboard; 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', () => { this.input.on('pointerdown', () => {
if (!this.input.mouse?.locked) { if (!this.input.mouse?.locked) {
this.input.mouse?.requestPointerLock(); this.input.mouse?.requestPointerLock();
} else {
this.fire();
} }
}); });
@ -123,18 +156,48 @@ export class GameScene extends Phaser.Scene {
} }
fire() { 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; 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'); this.game.events.emit('player-empty');
return; return;
} }
if (result.killed) this.player.score += result.hit.def.points ?? 100; const { cam } = this.player;
this.game.events.emit('player-fired', result); // 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) { update(_time, deltaMs) {
// Dead: freeze the world (still drawn) until the delayed restart fires.
if (this.playerDead) return;
const dt = deltaMs / 1000; const dt = deltaMs / 1000;
// --- Build movement intent from keyboard + accumulated mouse look. --- // --- 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); this.player.update(this.map, input, dt);
// Keyboard fire fallback (Space), useful before grabbing the mouse. // Number keys switch to an owned weapon; unowned guns flash a message.
if (k.SPACE.isDown) this.fire(); 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). // E uses the door ahead (open, or close an open one).
if (Phaser.Input.Keyboard.JustDown(k.E)) this.tryUseDoor(); if (Phaser.Input.Keyboard.JustDown(k.E)) this.tryUseDoor();
@ -167,14 +246,37 @@ export class GameScene extends Phaser.Scene {
y: Math.floor(cam.posY), 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(); this.handlePickups();
// No-exit levels finish when the last guard falls. // No-exit levels finish when the last guard falls.
if ( if (
!this.hasExit && !this.hasExit &&
this.hadEnemies && this.hadEnemies &&
!this.entities.some((e) => e.isEnemy && e.active) !this.entities.some((e) => e.alive)
) { ) {
this.completeLevel(); this.completeLevel();
} }
@ -183,12 +285,67 @@ export class GameScene extends Phaser.Scene {
this.raycaster.render(this.map, this.player.cam); this.raycaster.render(this.map, this.player.cam);
this.sprites.render( this.sprites.render(
this.player.cam, this.player.cam,
[...this.entities, ...this.props], [...this.entities, ...this.props, ...this.projectiles, ...this.effects],
this.raycaster.zBuffer, this.raycaster.zBuffer,
); );
this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU 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 /** Walk-over pickups: collect nearby active items; reaching an exit ends the
* level. */ * level. */
handlePickups() { handlePickups() {
@ -223,11 +380,12 @@ export class GameScene extends Phaser.Scene {
return; 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', { this.registry.set('carryStats', {
health: this.player.health, health: this.player.health,
ammo: this.player.ammo,
score: this.player.score, score: this.player.score,
weapons: [...this.player.weapons],
ammo: { ...this.player.ammo },
}); });
this.game.events.emit('level-complete'); this.game.events.emit('level-complete');
@ -242,7 +400,7 @@ export class GameScene extends Phaser.Scene {
const def = e.def; const def = e.def;
switch (def.effect) { switch (def.effect) {
case 'ammo': case 'ammo':
this.player.ammo += def.amount ?? 10; this.player.addAmmo(def.weapon ?? 'pistol', def.amount ?? 10);
break; break;
case 'health': case 'health':
this.player.health = Math.min(100, this.player.health + (def.amount ?? 25)); this.player.health = Math.min(100, this.player.health + (def.amount ?? 25));
@ -253,6 +411,13 @@ export class GameScene extends Phaser.Scene {
case 'key': case 'key':
if (def.key) this.player.keys.add(def.key); if (def.key) this.player.keys.add(def.key);
break; 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 Phaser from 'phaser';
import { listPickups } from '../../engine/registry.js'; import { listPickups, listWeapons } from '../../engine/registry.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
/** /**
* Overlay HUD: health / ammo / score readout, a crosshair, a stub weapon at the * Overlay HUD: health / ammo / score readout, a crosshair, the current
* bottom of the screen with a muzzle flash on fire, and a transient message * weapon's first-person viewmodel with a muzzle flash on fire, a red flash
* line. Runs in parallel with GameScene and reads its `player` each frame. * 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 { export class HudScene extends Phaser.Scene {
constructor() { constructor() {
@ -13,6 +14,14 @@ export class HudScene extends Phaser.Scene {
this.messageTimer = 0; 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() { create() {
this.gameScene = this.scene.get('GameScene'); 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, 2, 14, 0xffffff, 0.7);
this.add.rectangle(cx, cy, 14, 2, 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 this.weapon = this.add
.rectangle(cx, GAME_HEIGHT - 50, 70, 110, 0x444444) .image(cx, GAME_HEIGHT, '__DEFAULT')
.setStrokeStyle(2, 0x222222); .setOrigin(0.5, 1)
.setVisible(false);
this.muzzle = this.add this.muzzle = this.add
.ellipse(cx, GAME_HEIGHT - 110, 40, 30, 0xffd040) .ellipse(cx, GAME_HEIGHT - 240, 40, 30, 0xffd040)
.setAlpha(0); .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 this.victoryShown = false; // scene instances persist; reset per run
// Event wiring from the game. // Event wiring from the game.
@ -59,6 +79,12 @@ export class HudScene extends Phaser.Scene {
'player-fired': (result) => this.onFired(result), 'player-fired': (result) => this.onFired(result),
'player-empty': () => this.flash('*click* out of ammo'), 'player-empty': () => this.flash('*click* out of ammo'),
'player-pickup': (name) => this.flash(`Picked up ${name}`), '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}`), 'door-locked': (keyName) => this.flash(`Locked — you need the ${keyName}`),
'level-complete': () => this.flash('Level complete!'), 'level-complete': () => this.flash('Level complete!'),
'game-won': (score) => this.showVictory(score), '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. */ /** Full-screen "you escaped" overlay shown after the final level. */
showVictory(score) { showVictory(score) {
if (this.victoryShown) return; // guard against duplicate game-won events if (this.victoryShown) return; // guard against duplicate game-won events
@ -126,13 +175,9 @@ export class HudScene extends Phaser.Scene {
} }
onFired(result) { 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.muzzle.setAlpha(1);
this.weapon.y += 6; this.weapon.y += 6;
if (result.killed) {
const def = result.hit.def;
this.flash(`${def.name} down! +${def.points ?? 100}`);
}
} }
flash(msg) { flash(msg) {
@ -143,10 +188,27 @@ export class HudScene extends Phaser.Scene {
update(_time, deltaMs) { update(_time, deltaMs) {
const p = this.gameScene?.player; const p = this.gameScene?.player;
if (p) { if (p) {
const w = p.weapon;
const ammoLabel = w.melee ? '--' : (p.ammo[w.ammoId] ?? 0);
this.statusText.setText( 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). // Key chips, top-right (a few rects — cheap to redraw every frame).
this.keyGfx.clear(); this.keyGfx.clear();
let i = 0; 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) { if (this.muzzle.alpha > 0) {
this.muzzle.setAlpha(Math.max(0, this.muzzle.alpha - deltaMs / 80)); 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) { if (this.weapon.y > restY) {
this.weapon.y = Math.max(restY, this.weapon.y - deltaMs / 8); this.weapon.y = Math.max(restY, this.weapon.y - deltaMs / 8);
} }