Compare commits
4 Commits
965e4308c9
...
74f8d0c926
| Author | SHA1 | Date |
|---|---|---|
|
|
74f8d0c926 | |
|
|
9b3dccf4b5 | |
|
|
2cc075e8be | |
|
|
c86ccb9d1f |
|
|
@ -322,7 +322,20 @@
|
|||
"x": 2.5,
|
||||
"y": 18.5,
|
||||
"facing": 180,
|
||||
"patrol": []
|
||||
"patrol": [
|
||||
{
|
||||
"x": 2.5,
|
||||
"y": 12.5
|
||||
},
|
||||
{
|
||||
"x": 3.5,
|
||||
"y": 12.5
|
||||
},
|
||||
{
|
||||
"x": 3.5,
|
||||
"y": 18.5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": [],
|
||||
|
|
|
|||
|
|
@ -208,7 +208,17 @@
|
|||
"type": "guard",
|
||||
"x": 9.5,
|
||||
"y": 8.5,
|
||||
"facing": 180
|
||||
"facing": 180,
|
||||
"patrol": [
|
||||
{
|
||||
"x": 7.5,
|
||||
"y": 8.5
|
||||
},
|
||||
{
|
||||
"x": 12.5,
|
||||
"y": 8.5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
|
|
@ -11,6 +11,7 @@
|
|||
{ "key": "wolfenstein-item-health", "path": null },
|
||||
{ "key": "wolfenstein-bullet", "path": null },
|
||||
{ "key": "wolfenstein-muzzle", "path": null },
|
||||
{ "key": "wolfenstein-title", "path": null }
|
||||
{ "key": "wolfenstein-title", "path": null },
|
||||
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function ensureSprites(scene) {
|
|||
paintItem(scene, 'wolf-item-health', 0xe06c75);
|
||||
paintBullet(scene);
|
||||
paintMuzzleFlash(scene);
|
||||
paintWeaponPistol(scene);
|
||||
}
|
||||
|
||||
function paintGuard(scene) {
|
||||
|
|
@ -47,11 +48,23 @@ function paintItem(scene, key, color) {
|
|||
g.destroy();
|
||||
}
|
||||
|
||||
// A flat solid dot read as a blob at any size. A real round has a dark
|
||||
// brass/copper body (so it reads against bright walls, not just dark ones),
|
||||
// a hot tracer core smaller than the body (implies motion/heat, not just a
|
||||
// colored ball), and a tiny off-center highlight for a touch of roundness —
|
||||
// three concentric circles instead of one, still cheap to generate.
|
||||
function paintBullet(scene) {
|
||||
const S = 16;
|
||||
const S = 12;
|
||||
const cx = S / 2, cy = S / 2;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x8a5a2a, 1);
|
||||
g.fillCircle(cx, cy, S * 0.42);
|
||||
g.lineStyle(1, 0x3a2410, 0.8);
|
||||
g.strokeCircle(cx, cy, S * 0.42);
|
||||
g.fillStyle(0xfff2a8, 1);
|
||||
g.fillCircle(S / 2, S / 2, S / 2);
|
||||
g.fillCircle(cx, cy, S * 0.22);
|
||||
g.fillStyle(0xffffff, 0.9);
|
||||
g.fillCircle(cx - S * 0.08, cy - S * 0.08, S * 0.07);
|
||||
g.generateTexture('wolf-bullet', S, S);
|
||||
g.destroy();
|
||||
}
|
||||
|
|
@ -64,3 +77,20 @@ function paintMuzzleFlash(scene) {
|
|||
g.generateTexture('wolf-muzzle', S, S);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
// Small bottom-anchored placeholder (not a full GAME_WIDTH x GAME_HEIGHT
|
||||
// canvas like the real weapon_pistol.png — see WolfensteinView's
|
||||
// hasRealWeaponArt branch, which anchors this one bottom-center instead of
|
||||
// at the origin).
|
||||
function paintWeaponPistol(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x1a1a1a, 1);
|
||||
g.fillRect(W * 0.38, H * 0.05, W * 0.14, H * 0.55);
|
||||
g.fillStyle(0x3a3a3a, 1);
|
||||
g.fillRect(W * 0.28, H * 0.5, W * 0.34, H * 0.16);
|
||||
g.fillStyle(0x1a120a, 1);
|
||||
g.fillRect(W * 0.34, H * 0.64, W * 0.16, H * 0.32);
|
||||
g.generateTexture('wolf-weapon-pistol', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
this.add.text(x, y + size + 16,
|
||||
'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n'
|
||||
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes',
|
||||
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop',
|
||||
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
|
||||
}
|
||||
|
||||
|
|
@ -746,6 +746,14 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
* enemy itself, so it's obvious which guard you're currently editing.
|
||||
* Home (the enemy's own spawn point) is always node 0 of the walked path,
|
||||
* even though it isn't stored in `patrol` — matches stepPatrol().
|
||||
*
|
||||
* There's no separate "loop" toggle: stepPatrol() closes the route into a
|
||||
* one-way loop automatically once 2+ waypoints are authored (a 0- or
|
||||
* 1-waypoint route ping-pongs, which looks identical to a loop for that
|
||||
* few points anyway), so the overlay draws the same way — an extra
|
||||
* closing segment from the last waypoint back to home whenever
|
||||
* `e.patrol.length >= 2`, so what you see here always matches how the
|
||||
* guard will actually walk it.
|
||||
*/
|
||||
drawPatrolRoutes() {
|
||||
const g = this.g;
|
||||
|
|
@ -754,6 +762,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
for (const e of lvl.enemies) {
|
||||
if (!e.patrol || !e.patrol.length) continue;
|
||||
const selected = e === this.selectedEnemy;
|
||||
const looped = e.patrol.length >= 2;
|
||||
const path = [{ x: e.x, y: e.y }, ...e.patrol];
|
||||
g.lineStyle(selected ? 3 : 2, selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.55);
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
|
|
@ -761,6 +770,11 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const [bx, by] = this.toBoard(path[i + 1].x, path[i + 1].y);
|
||||
g.lineBetween(ax, ay, bx, by);
|
||||
}
|
||||
if (looped) {
|
||||
const [ax, ay] = this.toBoard(path[path.length - 1].x, path[path.length - 1].y);
|
||||
const [bx, by] = this.toBoard(path[0].x, path[0].y);
|
||||
g.lineBetween(ax, ay, bx, by);
|
||||
}
|
||||
g.fillStyle(selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.7);
|
||||
for (const node of e.patrol) {
|
||||
const [nx, ny] = this.toBoard(node.x, node.y);
|
||||
|
|
|
|||
|
|
@ -247,6 +247,15 @@ function stepDoors(state, rules) {
|
|||
// Enemy AI — idle -> alert -> chase -> attack -> dead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Total field-of-view width an idle/patrolling guard can spot the player
|
||||
// in, centered on its own heading (e.angle) — ±45° either side. Only
|
||||
// matters for the idle->alert transition below: once alerted, e.angle gets
|
||||
// re-pointed straight at the player every tick (see the unconditional
|
||||
// assignment further down), so it trivially stays inside its own cone for
|
||||
// the rest of a chase/attack — this never causes an alerted guard to "lose"
|
||||
// the player just because they circled behind it.
|
||||
const ENEMY_FOV_RAD = Math.PI / 2;
|
||||
|
||||
function stepEnemyAI(state, rules) {
|
||||
const p = state.player;
|
||||
for (const e of state.enemies) {
|
||||
|
|
@ -255,7 +264,9 @@ function stepEnemyAI(state, rules) {
|
|||
if (p.dead) { e.state = 'idle'; continue; }
|
||||
|
||||
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
|
||||
const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
|
||||
const angleToPlayer = Math.atan2(p.y - e.y, p.x - e.x);
|
||||
const inFov = Math.abs(angleDiff(angleToPlayer, e.angle)) <= ENEMY_FOV_RAD / 2;
|
||||
const canSee = distToPlayer <= def.detectRange && inFov && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
|
||||
|
||||
if (e.state === 'idle') {
|
||||
if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); continue; }
|
||||
|
|
@ -292,8 +303,20 @@ function stepEnemyAI(state, rules) {
|
|||
}
|
||||
|
||||
const PATROL_ARRIVE_DIST = 0.12;
|
||||
// Patrolling is a leisurely walk, not the urgent pace of a chase — half of
|
||||
// def.speed (which chase/attack-approach movement still uses at full rate).
|
||||
const PATROL_SPEED_MULT = 0.5;
|
||||
|
||||
/** Ping-pong an idle enemy along [home, ...patrol]; a no-op if it has no route. */
|
||||
/**
|
||||
* Walk an idle enemy along [home, ...patrol]; a no-op if it has no route.
|
||||
* With 2+ authored waypoints (3+ points counting home), the route closes
|
||||
* into a one-way loop: home -> node1 -> ... -> nodeN -> home -> node1 -> ...
|
||||
* forever, via patrolIndex wrapping (patrolIndex + 1) % path.length, never
|
||||
* reversing. With 0 or 1 waypoints, home and the sole node are the same
|
||||
* "loop" either way round, so it just ping-pongs between them via
|
||||
* patrolDir (kept only for that 2-point case) — no editor UI to pick
|
||||
* loop-vs-ping-pong; it's implicit in how many waypoints were authored.
|
||||
*/
|
||||
function stepPatrol(state, rules, e, def) {
|
||||
if (!e.patrol || !e.patrol.length) return;
|
||||
const path = [{ x: e.homeX, y: e.homeY }, ...e.patrol];
|
||||
|
|
@ -302,13 +325,18 @@ function stepPatrol(state, rules, e, def) {
|
|||
const dx = target.x - e.x, dy = target.y - e.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= PATROL_ARRIVE_DIST) {
|
||||
let next = e.patrolIndex + e.patrolDir;
|
||||
if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; }
|
||||
let next;
|
||||
if (e.patrol.length >= 2) {
|
||||
next = (e.patrolIndex + 1) % path.length;
|
||||
} else {
|
||||
next = e.patrolIndex + e.patrolDir;
|
||||
if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; }
|
||||
}
|
||||
e.patrolIndex = next;
|
||||
return;
|
||||
}
|
||||
const mvx = (dx / dist) * def.speed * rules.dt;
|
||||
const mvy = (dy / dist) * def.speed * rules.dt;
|
||||
const mvx = (dx / dist) * def.speed * PATROL_SPEED_MULT * rules.dt;
|
||||
const mvy = (dy / dist) * def.speed * PATROL_SPEED_MULT * rules.dt;
|
||||
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
||||
e.angle = Math.atan2(dy, dx);
|
||||
}
|
||||
|
|
@ -317,15 +345,82 @@ function stepPatrol(state, rules, e, def) {
|
|||
// Weapons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Smallest signed difference a-b, wrapped to [-pi, pi] — shared by the melee arc check below and the enemy vision cone above. */
|
||||
function angleDiff(a, b) {
|
||||
const d = a - b;
|
||||
return Math.atan2(Math.sin(d), Math.cos(d));
|
||||
}
|
||||
|
||||
function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) {
|
||||
const dx = tx - px, dy = ty - py;
|
||||
if (Math.hypot(dx, dy) > range) return false;
|
||||
const toTarget = Math.atan2(dy, dx);
|
||||
let diff = toTarget - pAngle;
|
||||
diff = Math.atan2(Math.sin(diff), Math.cos(diff));
|
||||
const diff = angleDiff(toTarget, pAngle);
|
||||
return Math.abs(diff) <= arcRad / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood-fills the map's floor cells into rooms: a maximal connected region
|
||||
* of open (wallType 0) cells, where a door cell — whatever its live
|
||||
* open/closed `slide` — always counts as a boundary, never as floor. That's
|
||||
* why this can't just read `map.walls[y][x] > 0` on its own: stepDoors
|
||||
* zeroes a door cell's wall-grid entry while it's fully open (see
|
||||
* DOOR_WALL_TYPE's other callers), which is exactly right for
|
||||
* movement/LOS/rendering but wrong here — two rooms joined only by a door
|
||||
* must stay two rooms, open or shut, per alertEnemiesInPlayerRoom's
|
||||
* "gunfire carries through the room, not through doorways" contract. So a
|
||||
* door's cell is always excluded via `doors` (permanent x/y entries, unlike
|
||||
* `slide`) regardless of what the live grid says at that cell right now.
|
||||
* Cheap enough (every level here is well under 1000 cells) to just
|
||||
* recompute on demand rather than caching on state.
|
||||
*/
|
||||
function computeRooms(map, doors) {
|
||||
const doorCells = new Set(doors.map((d) => `${d.x},${d.y}`));
|
||||
const blocked = (x, y) => doorCells.has(`${x},${y}`) || map.walls[y][x] > 0;
|
||||
const roomOf = Array.from({ length: map.height }, () => new Array(map.width).fill(-1));
|
||||
let nextRoom = 0;
|
||||
for (let y = 0; y < map.height; y++) {
|
||||
for (let x = 0; x < map.width; x++) {
|
||||
if (roomOf[y][x] !== -1 || blocked(x, y)) continue;
|
||||
const id = nextRoom++;
|
||||
const stack = [[x, y]];
|
||||
roomOf[y][x] = id;
|
||||
while (stack.length) {
|
||||
const [cx, cy] = stack.pop();
|
||||
for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
|
||||
if (nx < 0 || ny < 0 || nx >= map.width || ny >= map.height) continue;
|
||||
if (roomOf[ny][nx] !== -1 || blocked(nx, ny)) continue;
|
||||
roomOf[ny][nx] = id;
|
||||
stack.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return roomOf;
|
||||
}
|
||||
|
||||
/**
|
||||
* A gunshot alerts every idle guard sharing the player's room, regardless
|
||||
* of range/LOS/facing-cone — those only gate the "spotted visually" path in
|
||||
* stepEnemyAI; this is "heard it," a separate trigger. Already-alert/
|
||||
* chase/attack guards are left alone (nothing to escalate), and a shot
|
||||
* fired from inside a door cell (roomOf has no entry for doorways
|
||||
* themselves) simply alerts no one via this path.
|
||||
*/
|
||||
function alertEnemiesInPlayerRoom(state) {
|
||||
const roomOf = computeRooms(state.map, state.doors);
|
||||
const p = state.player;
|
||||
const playerRoom = roomOf[Math.floor(p.y)]?.[Math.floor(p.x)] ?? -1;
|
||||
if (playerRoom < 0) return;
|
||||
for (const e of state.enemies) {
|
||||
if (e.dead || e.state !== 'idle') continue;
|
||||
if (roomOf[Math.floor(e.y)]?.[Math.floor(e.x)] === playerRoom) {
|
||||
e.state = 'alert';
|
||||
state.events.push({ t: 'enemyAlert', id: e.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fireWeapon(state, rules) {
|
||||
const p = state.player;
|
||||
const w = rules.weaponById[p.weapon];
|
||||
|
|
@ -357,6 +452,7 @@ export function fireWeapon(state, rules) {
|
|||
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
|
||||
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
||||
});
|
||||
alertEnemiesInPlayerRoom(state);
|
||||
}
|
||||
|
||||
function spawnProjectile(state, rules, opts) {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,18 @@ const DEATH_FALL_MS = 350;
|
|||
const DEATH_GROUND_MS = 2000;
|
||||
const DEATH_FADE_MS = 800;
|
||||
|
||||
// POV weapon viewmodel — sits above the 3D view canvas (depth 10) and the
|
||||
// sprite billboards, below the bottom HUD bar (depth 20, see
|
||||
// WolfensteinGame._buildHud) so it peeks up from behind the ammo bar like a
|
||||
// classic FPS viewmodel, and below the crosshair (depth 15).
|
||||
const WEAPON_DEPTH = 12;
|
||||
// Bob/sway share one phase accumulator that only advances while the player
|
||||
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
|
||||
const WEAPON_BOB_SPEED = 0.012;
|
||||
const WEAPON_BOB_AMP_Y = 16; // px, vertical footstep bounce (|sin|, so it's a bounce not a swing)
|
||||
const WEAPON_SWAY_AMP_X = 10; // px, horizontal side-to-side sway
|
||||
const WEAPON_BOB_SMOOTH_MS = 220; // how fast bob strength ramps in/out on start/stop
|
||||
|
||||
export default class WolfensteinView {
|
||||
constructor(scene, rules) {
|
||||
this.scene = scene;
|
||||
|
|
@ -77,11 +89,31 @@ export default class WolfensteinView {
|
|||
? scene.textures.get('wolfenstein-doors') : null;
|
||||
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
|
||||
? scene.textures.get('wolfenstein-guard-sheet') : null;
|
||||
|
||||
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT
|
||||
// (1920x1080) with the gun already positioned bottom-center against a
|
||||
// transparent background, so it's just placed at the origin. The
|
||||
// procedural fallback is a small bottom-anchored placeholder instead
|
||||
// (see WolfensteinArt.paintWeaponPistol), so the two branches need
|
||||
// different origin/base-position setup.
|
||||
this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol');
|
||||
this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol';
|
||||
if (this.hasRealWeaponArt) {
|
||||
this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false);
|
||||
this._weaponBaseX = 0; this._weaponBaseY = 0;
|
||||
} else {
|
||||
this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false);
|
||||
this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H;
|
||||
}
|
||||
this._weaponBobPhase = 0;
|
||||
this._weaponBobStrength = 0;
|
||||
this._lastWeaponNow = null;
|
||||
}
|
||||
|
||||
render(state, camera) {
|
||||
this._drawWalls(state.map, state.doors, camera);
|
||||
this._drawSprites(state, camera);
|
||||
this._drawWeapon(state);
|
||||
}
|
||||
|
||||
_drawWalls(map, doors, camera) {
|
||||
|
|
@ -197,7 +229,7 @@ export default class WolfensteinView {
|
|||
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 });
|
||||
}
|
||||
for (const proj of state.projectiles) {
|
||||
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.18 });
|
||||
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 });
|
||||
}
|
||||
|
||||
const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY);
|
||||
|
|
@ -234,6 +266,37 @@ export default class WolfensteinView {
|
|||
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* POV pistol viewmodel. Visible only while pistol is the equipped weapon
|
||||
* (`p.weapon`) — fists has no viewmodel art, so switching to it just hides
|
||||
* this image rather than swapping textures. Bob (vertical "footstep"
|
||||
* bounce) and sway (horizontal drift) are both driven off one phase
|
||||
* accumulator that only advances while the player has forward/strafe
|
||||
* input held; `_weaponBobStrength` is lerped toward 1 while moving and 0
|
||||
* while still, so starting/stopping fades the motion in/out instead of
|
||||
* snapping to it.
|
||||
*/
|
||||
_drawWeapon(state) {
|
||||
const p = state.player;
|
||||
const img = this.weaponImage;
|
||||
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; }
|
||||
|
||||
const now = this.scene.time.now;
|
||||
const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0;
|
||||
this._lastWeaponNow = now;
|
||||
|
||||
const moving = p.moveForward !== 0 || p.moveStrafe !== 0;
|
||||
const smoothT = Math.min(1, dt / WEAPON_BOB_SMOOTH_MS);
|
||||
this._weaponBobStrength += ((moving ? 1 : 0) - this._weaponBobStrength) * smoothT;
|
||||
if (moving) this._weaponBobPhase += dt * WEAPON_BOB_SPEED;
|
||||
|
||||
const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength;
|
||||
const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
|
||||
|
||||
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY);
|
||||
img.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Falling -> on-the-ground -> fade-out sequence for a just-killed guard,
|
||||
* keyed by enemy id in `this.deathAnim` (first call after `e.dead` flips
|
||||
|
|
@ -278,8 +341,9 @@ export default class WolfensteinView {
|
|||
* guard always shows it head-on regardless of viewing angle (attacking
|
||||
* guards don't move, so the player is almost always roughly in front of
|
||||
* them anyway). `flip` mirrors the single "facing right" pose for the
|
||||
* left side — if left/right ever reads backwards in-game, flip the sign
|
||||
* on the `rel > 0` line below, nothing else needs to change.
|
||||
* left side — was `rel > 0` originally, but that read backwards in-game
|
||||
* (2026-08-21), so it's `rel < 0` now. If it's ever wrong again, flip
|
||||
* this one sign; nothing else needs to change.
|
||||
*/
|
||||
_guardFacing(e, camera, now) {
|
||||
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
|
||||
|
|
@ -299,7 +363,7 @@ export default class WolfensteinView {
|
|||
if (absRel > (3 * Math.PI) / 4) {
|
||||
return { frame: walkOn ? GUARD_FRAME.backWalk : GUARD_FRAME.backIdle, flip: false };
|
||||
}
|
||||
return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel > 0 };
|
||||
return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel < 0 };
|
||||
}
|
||||
|
||||
_ensureSprite(key, tex) {
|
||||
|
|
@ -325,6 +389,7 @@ export default class WolfensteinView {
|
|||
for (const img of this.spritePool.values()) img.destroy();
|
||||
this.spritePool.clear();
|
||||
this.image?.destroy();
|
||||
this.weaponImage?.destroy();
|
||||
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,9 +169,12 @@ same aspect ratio is what matters most):
|
|||
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
|
||||
— pickup icons. Placeholder is 64×64; square, transparent background.
|
||||
Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`).
|
||||
- `wolfenstein-bullet` — pistol round in flight. Placeholder is 16×16, a
|
||||
small bright circle; rendered at scale 0.18, so keep it simple/legible at
|
||||
tiny sizes.
|
||||
- `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see
|
||||
`WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer
|
||||
core and a small highlight, three concentric circles); rendered at scale
|
||||
0.1 (`WolfensteinView._drawSprites`, shrunk from 0.18 on 2026-08-21 to
|
||||
read as a small round rather than a floating dot) — keep any replacement
|
||||
simple/legible at tiny sizes, same as before.
|
||||
- `wolfenstein-muzzle` — muzzle-flash sprite. Placeholder is 48×48. Not
|
||||
currently spawned by the scene at all (no code creates a `wolf-muzzle`
|
||||
image instance yet, painted or not) — wire it up on `weaponFired` events
|
||||
|
|
@ -182,6 +185,31 @@ same aspect ratio is what matters most):
|
|||
`TAScreens.js`'s style before `ta-background` was painted). If added,
|
||||
size it to the shared canvas, **1920×1080**, and code would need to draw
|
||||
it behind that panel.
|
||||
- `wolfenstein-weapon-pistol` — **wired up as of 2026-08-21.** POV pistol
|
||||
viewmodel. Unlike the other entries here, this one is sized to the full
|
||||
shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent
|
||||
background, gun art pre-positioned bottom-center** — `WolfensteinView`
|
||||
just places the whole image at the origin `(0, 0)` rather than treating it
|
||||
as a small floating icon, so the gun's position in the frame comes from
|
||||
where it's drawn in the PNG, not from any offset in code. Depth 12: above
|
||||
the 3D view canvas and sprite billboards (10), below the crosshair (15)
|
||||
and bottom HUD bar (20) — the lower part of the gun art tucks behind the
|
||||
ammo bar exactly like a classic FPS viewmodel. Shown only while
|
||||
`state.player.weapon === 'pistol'` (fists has no viewmodel art, so
|
||||
switching to fists just hides this image rather than swapping textures).
|
||||
`WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce,
|
||||
`Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase *
|
||||
0.5)`) while the player has forward/strafe input held, both driven off one
|
||||
shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player
|
||||
stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in
|
||||
and out instead of snapping, so starting/stopping a step doesn't jerk the
|
||||
gun. Falls back to `WolfensteinArt.paintWeaponPistol` (a small 360×260
|
||||
procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)`
|
||||
instead of the origin) if the sheet isn't loaded — that's the one entry in
|
||||
this file where the real-art and placeholder branches use genuinely
|
||||
different Phaser image setup (origin/base position), not just a different
|
||||
texture key, because the real PNG carries its own positioning and the
|
||||
placeholder can't.
|
||||
|
||||
## Sound effects
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ lvl('e1m2', 'The Armory', `
|
|||
`, {
|
||||
campaignId: 'episode1', missionIndex: 1,
|
||||
briefing: ['Tougher resistance ahead.', 'The armory door only opens from this side.'],
|
||||
// The second guard (E index 1, at 9,8 — reading order top-to-bottom,
|
||||
// left-to-right) walks the open floor along its own row; the first guard
|
||||
// stays put. See wolfensteinMap.js's legend comment for the opts.patrols
|
||||
// convention.
|
||||
patrols: { 1: [[7, 8], [12, 8]] },
|
||||
});
|
||||
|
||||
lvl('e2m1', 'Descent', `
|
||||
|
|
|
|||
|
|
@ -195,6 +195,135 @@ section('4. Enemy AI');
|
|||
check('guard with clear line of sight alerts promptly', alerted);
|
||||
}
|
||||
|
||||
// Clear line of sight, but the player is behind the guard (facing 0 = east,
|
||||
// player is to the west) — outside the 90 degree vision cone, so it should
|
||||
// never alert no matter how long it stands there.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 0 }] };
|
||||
const state = L.createState(level, rules);
|
||||
for (let i = 0; i < 300; i++) L.tick(state, rules);
|
||||
check('guard with player outside its vision cone stays idle', state.enemies[0].state === 'idle', state.enemies[0].state);
|
||||
}
|
||||
|
||||
// Same idea, but the player sits just inside the +-45 degree cone edge
|
||||
// (~44 degrees off the guard's heading, facing 180 = west) — should still
|
||||
// alert. Kept away from the row-3 internal wall so LOS itself stays clear.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 6.4, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 4.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
let alerted = false;
|
||||
for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; }
|
||||
check('guard alerts to a player near the cone edge but still inside it', alerted);
|
||||
}
|
||||
|
||||
// Patrol routes (stepPatrol): an idle guard with no player interference
|
||||
// should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard
|
||||
// and its whole route stay strictly west of the row-3 wall (x < 3), player
|
||||
// stays strictly east of it (x = 6.5) — a straight line between them at
|
||||
// y=3.5 always crosses the wall cells at x=3/4 regardless of where along
|
||||
// its route the guard currently is, so this isolates pure patrol movement
|
||||
// from AI detection (never alerts here, unlike the two tests above).
|
||||
{
|
||||
const level = {
|
||||
...baseLevel, playerStart: { x: 6.5, y: 3.5, angle: 0 },
|
||||
enemies: [{ type: 'guard', x: 1.5, y: 3.5, facing: 0, patrol: [{ x: 2.5, y: 3.5 }] }],
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
let minX = Infinity, maxX = -Infinity, everAlerted = false;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
L.tick(state, rules);
|
||||
const e = state.enemies[0];
|
||||
if (e.state !== 'idle') everAlerted = true;
|
||||
minX = Math.min(minX, e.x); maxX = Math.max(maxX, e.x);
|
||||
}
|
||||
check('patrolling guard never alerts (LOS blocked its whole route)', !everAlerted);
|
||||
check('a 1-waypoint route ping-pongs between home and the node', minX <= 1.6 && maxX >= 2.3, `x range [${minX.toFixed(2)}, ${maxX.toFixed(2)}]`);
|
||||
}
|
||||
|
||||
// A route with 2+ authored waypoints closes into a one-way loop instead
|
||||
// (home -> node1 -> node2 -> home -> ..., wrapping via
|
||||
// (patrolIndex + 1) % path.length, never reversing — see stepPatrol's
|
||||
// doc comment). Isolated from AI detection by distance alone here (a
|
||||
// bigger, wall-free map, player far outside detectRange=8 the whole
|
||||
// route) rather than the wall trick above, since the route touches more
|
||||
// than one row/column this time.
|
||||
{
|
||||
const W = 14, H = 14;
|
||||
const walls = [];
|
||||
for (let y = 0; y < H; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < W; x++) row.push(x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0);
|
||||
walls.push(row);
|
||||
}
|
||||
const level = {
|
||||
width: W, height: H, cellSize: 64, walls, doors: [], items: [],
|
||||
playerStart: { x: 12.5, y: 12.5, angle: 0 },
|
||||
enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0, patrol: [{ x: 3.5, y: 1.5 }, { x: 1.5, y: 3.5 }] }],
|
||||
exit: { x: 12.5, y: 1.5, radius: 0.6 },
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
const indices = [state.enemies[0].patrolIndex];
|
||||
let everAlerted = false;
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
L.tick(state, rules);
|
||||
const e = state.enemies[0];
|
||||
if (e.state !== 'idle') everAlerted = true;
|
||||
if (e.patrolIndex !== indices[indices.length - 1]) indices.push(e.patrolIndex);
|
||||
}
|
||||
let cyclesForward = indices.length >= 6;
|
||||
for (let i = 1; i < indices.length && cyclesForward; i++) {
|
||||
if (indices[i] !== (indices[i - 1] + 1) % 3) cyclesForward = false;
|
||||
}
|
||||
check('a 2-waypoint route never alerts (player far outside detectRange)', !everAlerted);
|
||||
check('a 2-waypoint route closes into a one-way loop, never reversing', cyclesForward, indices.slice(0, 9).join(','));
|
||||
}
|
||||
|
||||
// Gunfire alerts every idle guard sharing the player's room
|
||||
// (alertEnemiesInPlayerRoom/computeRooms), independent of range/LOS/cone
|
||||
// — and independent of whether a connecting door is open or closed, since
|
||||
// a door is always a room boundary for this purpose. No L.tick() calls
|
||||
// anywhere in this block, so nothing but fireWeapon's new room-alert path
|
||||
// can move a guard out of 'idle' here — a clean isolation from the
|
||||
// ordinary spot-the-player AI in stepEnemyAI.
|
||||
{
|
||||
const W = 11, H = 5;
|
||||
const walls = [];
|
||||
for (let y = 0; y < H; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < W; x++) {
|
||||
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
|
||||
const divider = x === 5 && (y === 1 || y === 3); // leaves (5,2) open for the door
|
||||
row.push(border || divider ? 1 : 0);
|
||||
}
|
||||
walls.push(row);
|
||||
}
|
||||
const doors = [{ x: 5, y: 2, orientation: 'vertical' }];
|
||||
const twoRoomLevel = (enemyX, enemyY) => ({
|
||||
width: W, height: H, cellSize: 64, walls, doors, items: [],
|
||||
playerStart: { x: 2.5, y: 2.5, angle: 0 },
|
||||
enemies: [{ type: 'guard', x: enemyX, y: enemyY, facing: 180 }],
|
||||
exit: { x: 9.5, y: 1.5, radius: 0.6 },
|
||||
});
|
||||
|
||||
{
|
||||
const state = L.createState(twoRoomLevel(3.5, 1.5), rules); // same room (left, x 1-4)
|
||||
L.fireWeapon(state, rules);
|
||||
check('gunshot alerts an idle guard sharing the room', state.enemies[0].state === 'alert', state.enemies[0].state);
|
||||
}
|
||||
{
|
||||
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // other room (right, x 6-9), door closed
|
||||
L.fireWeapon(state, rules);
|
||||
check('gunshot does not alert a guard behind a closed door', state.enemies[0].state === 'idle', state.enemies[0].state);
|
||||
}
|
||||
{
|
||||
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // same, door forced fully open
|
||||
state.doors[0].slide = 1; state.doors[0].target = 1;
|
||||
state.map.walls[2][5] = 0;
|
||||
L.fireWeapon(state, rules);
|
||||
check('gunshot does not alert a guard through an open door either', state.enemies[0].state === 'idle', state.enemies[0].state);
|
||||
}
|
||||
}
|
||||
|
||||
// A dead guard stops acting and can't be hit/killed twice.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@
|
|||
// Every row must be the same length; the outer ring must be entirely wall/door
|
||||
// (auditLevel below hard-rejects an open boundary or an unreachable exit —
|
||||
// the same BFS-solvability gate tools/genBloxorz.js uses).
|
||||
//
|
||||
// Patrol routes can't be expressed as single ASCII characters (a route is a
|
||||
// variable-length list of waypoints, not a single cell), so they're passed
|
||||
// separately via `opts.patrols` instead: `{ 0: [[x,y], [x,y]], ... }`, keyed
|
||||
// by each guard's 0-based index in reading order (top-to-bottom,
|
||||
// left-to-right scan of the 'E' characters — the same order `enemies` gets
|
||||
// built in below). Coordinates are cell coords, auto-offset to the cell
|
||||
// center exactly like every other symbol here. Matches WolfensteinLogic's
|
||||
// `patrol` field (an idle guard ping-pongs home -> patrol[0] -> ... -> home,
|
||||
// see stepPatrol) and the editor's Patrol tool — a level authored here and
|
||||
// one with a patrol route drawn in the editor are indistinguishable to
|
||||
// WolfensteinLogic either way.
|
||||
|
||||
import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js';
|
||||
|
||||
|
|
@ -53,6 +65,12 @@ export function parseMap(id, name, art, opts = {}) {
|
|||
if (!playerStart) throw new Error(`level ${id}: missing S (player start)`);
|
||||
if (!exit) throw new Error(`level ${id}: missing X (exit)`);
|
||||
|
||||
for (const [idx, nodes] of Object.entries(opts.patrols ?? {})) {
|
||||
const enemy = enemies[Number(idx)];
|
||||
if (!enemy) throw new Error(`level ${id}: patrols[${idx}] has no matching E (only ${enemies.length} enemies)`);
|
||||
enemy.patrol = nodes.map(([nx, ny]) => ({ x: nx + 0.5, y: ny + 0.5 }));
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1, id, name,
|
||||
campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0,
|
||||
|
|
|
|||
Loading…
Reference in New Issue