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.
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 843 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 501 KiB |
90
sprites.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||