wolfenstein: pan/zoom editor with minimap, sliding recessed doors, enemy patrol
Editor (WolfensteinEditor.js): - Decouple the board from grid size: fixed 900px viewport with independent pan (right-drag / WASD / arrows) and zoom (wheel / +/- / F to fit) instead of squeezing the whole grid in; add a whole-level map overview minimap with click-to-jump. - Grids up to 1000 cells/side (was 48); painting past the edge auto-grows the grid (preserving content, re-sealing borders) instead of being refused. - Rebuild the toolbar as a categorized DOM radio + dropdown panel (Wall / Door / Pickup / Enemy / Patrol / Zone / Erase) instead of a flat list. - Add a Patrol tool: select a guard, then click tiles to add/remove waypoints; routes are drawn (dim for all, highlighted for the selection). - Throttle the reachability validate during paint-drag; flush it before Test Play / Export; queue game assets in preload() like ZumaEditor. Doors (WolfensteinLogic.js, WolfensteinRaycaster.js, WolfensteinView.js): - Player now opens doors by pressing Space (HUD "[SPACE] Open" prompt when one is in range); enemies still shove them open. Doors animate open/closed via a slide value (400ms) instead of popping, and stay solid for gameplay until fully open. - Render doors as recessed mid-cell geometry (real depth, producing the "H" doorway shape) via a door-aware render-only raycast — collision/LOS are unchanged; the door visibly slides sideways into a dark socket as it opens. Enemy AI (WolfensteinLogic.js): - Idle enemies with an authored patrol route ping-pong along [home, ...nodes] until they spot the player; validate patrol nodes. Rendering (WolfensteinView.js, assets, data): - Add textured wall rendering: per-column 1px source-texel sampling from the new walls.png spritesheet, darkened with a 'multiply' grey to match the existing side/fog shading, with per-wall-type flat-color fallback. - Wire in assets/images/wolfenstein/walls.png and point the art manifest at it. Misc: - level-e1m1.json: reworked to a taller (20-row) map exercising the new wall/door types; playerRadius 0.25 -> 0.35. - sprites.md: document exact wall-sheet size, the sliding/recessed door behavior, guard sheet usage, and standalone image sizes/proportions.
This commit is contained in:
parent
103bc235a7
commit
a336376a93
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"id": "e1m1",
|
||||
"id": "level-new",
|
||||
"name": "First Blood",
|
||||
"campaignId": "episode1",
|
||||
"campaignId": null,
|
||||
"missionIndex": 0,
|
||||
"width": 12,
|
||||
"height": 10,
|
||||
"height": 20,
|
||||
"cellSize": 64,
|
||||
"walls": [
|
||||
[
|
||||
|
|
@ -27,8 +27,8 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
|
|
@ -41,8 +41,8 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
|
|
@ -70,11 +70,25 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
|
|
@ -84,25 +98,11 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
|
|
@ -136,17 +136,157 @@
|
|||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4
|
||||
]
|
||||
],
|
||||
"playerStart": {
|
||||
|
|
@ -156,38 +296,40 @@
|
|||
},
|
||||
"doors": [
|
||||
{
|
||||
"x": 4,
|
||||
"x": 5,
|
||||
"y": 3,
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 7,
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 9,
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 18,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"type": "guard",
|
||||
"x": 8.5,
|
||||
"y": 1.5,
|
||||
"facing": 180
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"type": "ammo",
|
||||
"x": 3.5,
|
||||
"y": 7.5
|
||||
},
|
||||
{
|
||||
"type": "health",
|
||||
"x": 9.5,
|
||||
"y": 7.5
|
||||
"x": 2.5,
|
||||
"y": 18.5,
|
||||
"facing": 180,
|
||||
"patrol": []
|
||||
}
|
||||
],
|
||||
"items": [],
|
||||
"exit": {
|
||||
"x": 10.5,
|
||||
"y": 1.5,
|
||||
"y": 14.5,
|
||||
"radius": 0.6
|
||||
},
|
||||
"briefing": [
|
||||
"Clear the outpost.",
|
||||
"Find the exit."
|
||||
]
|
||||
"briefing": []
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
|
||||
"sheets": {
|
||||
"walls": { "key": "wolfenstein-walls", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }
|
||||
},
|
||||
"artwork": [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"tickHz": 60,
|
||||
"fovDeg": 66,
|
||||
"playerSpeed": 3.2,
|
||||
"playerRadius": 0.25,
|
||||
"playerRadius": 0.35,
|
||||
"playerMaxHealth": 100,
|
||||
"mouseSensitivity": 0.0022,
|
||||
"turnKeysRadPerSec": 2.6,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,21 @@
|
|||
// from WolfensteinLogic.js verbatim — the same functions the runtime loader
|
||||
// and tools/genWolfenstein.js use — so the editor can never disagree with the
|
||||
// game about what a legal level is.
|
||||
//
|
||||
// Grids up to MAX_GRID cells per side are supported (levels stay a plain
|
||||
// dense JSON `walls` array — no chunking/streaming, so there's a hard bound,
|
||||
// not literal infinity; MAX_GRID was picked so the reachability BFS validateLevel
|
||||
// runs stays under ~250ms worst case). The BOARD is a fixed-size viewport
|
||||
// (BOARD_SIZE px) onto that grid — not "whole grid squeezed into the box"
|
||||
// like it used to be — so editing pans/zooms (right-drag / WASD-arrows / wheel
|
||||
// / +-) instead of shrinking cells to fit. The MAP OVERVIEW minimap gives a
|
||||
// whole-level view for navigation and click-to-jump.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { buildLevelModel, validateLevel } from './WolfensteinLogic.js';
|
||||
import { queueGameAssets } from '../../services/assetLoader.js';
|
||||
|
||||
const BOARD_X = 70;
|
||||
const BOARD_Y = 70;
|
||||
|
|
@ -18,21 +28,54 @@ const BOARD_SIZE = 900;
|
|||
const GAMEDATA = 'assets/gamedata/wolfenstein';
|
||||
const FONT = '"Julius Sans One"';
|
||||
|
||||
// Screen pixels per cell. MIN_ZOOM caps how far "Fit View" zooms out on a
|
||||
// huge grid — below that, cells get too small to click reliably, so past
|
||||
// that point the main board shows a scrollable window instead of everything
|
||||
// at once (use the minimap to navigate). MAX_ZOOM is for fine detail work.
|
||||
const MIN_ZOOM = 4;
|
||||
const MAX_ZOOM = 160;
|
||||
const MAX_GRID = 1000;
|
||||
|
||||
const MINIMAP_SIZE = 300;
|
||||
|
||||
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
|
||||
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
|
||||
const MINIMAP_WALL_RGB = Object.fromEntries(
|
||||
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
|
||||
);
|
||||
|
||||
const TOOLS = [
|
||||
['wall1', 'Wall: Stone'],
|
||||
['wall2', 'Wall: Wood'],
|
||||
['wall3', 'Wall: Blue'],
|
||||
['wall4', 'Wall: Green'],
|
||||
['door', 'Door'],
|
||||
['start', 'Player Start'],
|
||||
['exit', 'Exit'],
|
||||
['enemy', 'Guard'],
|
||||
['ammo', 'Ammo Pickup'],
|
||||
['health', 'Health Pickup'],
|
||||
['erase', 'Erase'],
|
||||
// One radio row per category; a category with `options` gets a dropdown
|
||||
// next to its radio (even a category with only one option today, per
|
||||
// Door/Enemy — more sub-types land there later), a category with `options:
|
||||
// null` (just Erase) gets its label alone. The tool id actually used by
|
||||
// applyTool()/WALL_TOOLS is either the selected option's id, or — for a
|
||||
// dropdown-less category — the category's own id.
|
||||
const CATEGORIES = [
|
||||
{ id: 'wall', label: 'Wall', options: [
|
||||
{ id: 'wall1', label: 'Stone' },
|
||||
{ id: 'wall2', label: 'Wood' },
|
||||
{ id: 'wall3', label: 'Blue' },
|
||||
{ id: 'wall4', label: 'Green' },
|
||||
] },
|
||||
{ id: 'door', label: 'Door', options: [
|
||||
{ id: 'door', label: 'Normal' },
|
||||
] },
|
||||
{ id: 'pickup', label: 'Pickup', options: [
|
||||
{ id: 'health', label: 'Health' },
|
||||
{ id: 'ammo', label: 'Ammo' },
|
||||
] },
|
||||
{ id: 'enemy', label: 'Enemy', options: [
|
||||
{ id: 'enemy', label: 'Guard' },
|
||||
] },
|
||||
// Not a placement tool — a mode. Click an enemy to select it (see
|
||||
// applyPatrolTool), then click floor tiles to append/remove waypoints on
|
||||
// its route. No sub-types, so no dropdown, same as Erase.
|
||||
{ id: 'patrol', label: 'Patrol', options: null },
|
||||
{ id: 'zone', label: 'Zone', options: [
|
||||
{ id: 'start', label: 'Player Start' },
|
||||
{ id: 'exit', label: 'Exit' },
|
||||
] },
|
||||
{ id: 'erase', label: 'Erase', options: null },
|
||||
];
|
||||
|
||||
export default class WolfensteinEditor extends Phaser.Scene {
|
||||
|
|
@ -40,16 +83,35 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
init(data) { this.resume = !!data?.resume; }
|
||||
|
||||
preload() {
|
||||
// The editor boots straight out of PreloadScene, so the lazy game data
|
||||
// (wolfenstein-rules/campaigns JSON, sprite art — see
|
||||
// data/assetManifest.js) that GameRoomScene would normally fetch on
|
||||
// first entry isn't loaded yet. Test Play starts WolfensteinGame
|
||||
// directly, bypassing that fetch too, so queue it here instead — same
|
||||
// fix as ZumaEditor.js's preload().
|
||||
queueGameAssets(this, 'wolfenstein');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.level = this.resume ? (this.registry.get('wolfenstein-editor-state') ?? this.defaultLevel()) : this.defaultLevel();
|
||||
this.tool = 'wall1';
|
||||
this.undoStack = [];
|
||||
this.result = { valid: false, issues: [], reachable: false };
|
||||
this.panState = null;
|
||||
this._validateTimer = null;
|
||||
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
|
||||
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
|
||||
.setStrokeStyle(2, COLORS.accent);
|
||||
this.g = this.add.graphics().setDepth(5);
|
||||
// The board is now a fixed-size window onto a possibly much larger grid
|
||||
// (panned/zoomed independently) — clip strictly to the board rect so a
|
||||
// fractional pan offset never bleeds a partial cell past its border.
|
||||
const maskShape = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskShape.fillRect(BOARD_X, BOARD_Y, BOARD_SIZE, BOARD_SIZE);
|
||||
this.g.setMask(maskShape.createGeometryMask());
|
||||
|
||||
this.manifestLevels = [];
|
||||
fetch('data/wolfenstein-campaigns.json').then((r) => r.json())
|
||||
|
|
@ -60,8 +122,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
this.buildToolbar();
|
||||
this.buildLoadPanel();
|
||||
this.buildMinimap();
|
||||
this.bindPointer();
|
||||
this.bindKeys();
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
}
|
||||
|
||||
|
|
@ -81,14 +145,48 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Coordinate transforms ────────────────────────────────────────────────
|
||||
// ── Right-panel layout shared by the load panel and the minimap ────────────
|
||||
|
||||
get cellPx() { return Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height); }
|
||||
toBoard(cx, cy) { const k = this.cellPx; return [BOARD_X + cx * k, BOARD_Y + cy * k]; }
|
||||
toCell(px, py) { const k = this.cellPx; return [Math.floor((px - BOARD_X) / k), Math.floor((py - BOARD_Y) / k)]; }
|
||||
onBoard(p) {
|
||||
const w = this.cellPx * this.level.width, h = this.cellPx * this.level.height;
|
||||
return p.x >= BOARD_X && p.x <= BOARD_X + w && p.y >= BOARD_Y && p.y <= BOARD_Y + h;
|
||||
get rpX() { return BOARD_X + BOARD_SIZE + 90 + 260 + 40; }
|
||||
get rpW() { return 420; }
|
||||
|
||||
// ── Coordinate transforms ────────────────────────────────────────────────
|
||||
// (viewX, viewY) is the world cell shown at the board's top-left corner;
|
||||
// `zoom` is screen pixels per cell. Both are independent of grid size.
|
||||
|
||||
toBoard(cx, cy) { const k = this.zoom; return [BOARD_X + (cx - this.viewX) * k, BOARD_Y + (cy - this.viewY) * k]; }
|
||||
toCell(px, py) { const k = this.zoom; return [Math.floor(this.viewX + (px - BOARD_X) / k), Math.floor(this.viewY + (py - BOARD_Y) / k)]; }
|
||||
onBoard(p) { return p.x >= BOARD_X && p.x <= BOARD_X + BOARD_SIZE && p.y >= BOARD_Y && p.y <= BOARD_Y + BOARD_SIZE; }
|
||||
|
||||
/** [x0, y0, x1, y1) cell range currently on screen, clamped to the grid. */
|
||||
visibleCellRange() {
|
||||
const x0 = Math.max(0, Math.floor(this.viewX));
|
||||
const y0 = Math.max(0, Math.floor(this.viewY));
|
||||
const x1 = Math.min(this.level.width, Math.ceil(this.viewX + BOARD_SIZE / this.zoom) + 1);
|
||||
const y1 = Math.min(this.level.height, Math.ceil(this.viewY + BOARD_SIZE / this.zoom) + 1);
|
||||
return [x0, y0, x1, y1];
|
||||
}
|
||||
|
||||
/** Reset pan/zoom to show the whole grid (or as much as MIN_ZOOM allows). */
|
||||
fitToGrid() {
|
||||
this.zoom = Phaser.Math.Clamp(Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height), MIN_ZOOM, MAX_ZOOM);
|
||||
this.viewX = 0;
|
||||
this.viewY = 0;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
setView(vx, vy) {
|
||||
const visW = BOARD_SIZE / this.zoom, visH = BOARD_SIZE / this.zoom;
|
||||
this.viewX = Phaser.Math.Clamp(vx, -visW * 0.5, Math.max(-visW * 0.5, this.level.width - visW * 0.5));
|
||||
this.viewY = Phaser.Math.Clamp(vy, -visH * 0.5, Math.max(-visH * 0.5, this.level.height - visH * 0.5));
|
||||
this.draw();
|
||||
}
|
||||
|
||||
zoomAt(anchorX, anchorY, factor) {
|
||||
const worldX = this.viewX + (anchorX - BOARD_X) / this.zoom;
|
||||
const worldY = this.viewY + (anchorY - BOARD_Y) / this.zoom;
|
||||
this.zoom = Phaser.Math.Clamp(this.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
this.setView(worldX - (anchorX - BOARD_X) / this.zoom, worldY - (anchorY - BOARD_Y) / this.zoom);
|
||||
}
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -98,17 +196,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const bw = 260;
|
||||
this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5);
|
||||
|
||||
this.toolButtons = {};
|
||||
TOOLS.forEach(([id, label], i) => {
|
||||
const y = 80 + i * 58;
|
||||
const btn = new Button(this, tx + bw / 2, y, label, () => this.setTool(id), { width: bw, height: 48, fontSize: 18 });
|
||||
this.toolButtons[id] = btn;
|
||||
});
|
||||
this.setTool(this.tool);
|
||||
const panelBottom = this.buildToolPanel(tx, bw);
|
||||
|
||||
let y = 80 + TOOLS.length * 58 + 30;
|
||||
let y = panelBottom + 30;
|
||||
this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.level.name}`, () => this.renameLevel(), { width: bw, height: 48, fontSize: 16, variant: 'ghost' }); y += 58;
|
||||
new Button(this, tx + bw / 2, y, 'Resize Grid', () => this.resizeLevel(), { width: bw, height: 48, fontSize: 18 }); y += 58;
|
||||
new Button(this, tx + bw / 2, y, 'Fit View', () => this.fitToGrid(), { width: bw, height: 48, fontSize: 18 }); y += 58;
|
||||
new Button(this, tx + bw / 2, y, 'New Blank Level', () => this.newLevel(), { width: bw, height: 48, fontSize: 18 }); y += 66;
|
||||
|
||||
this.issueText = this.add.text(tx, y, '', {
|
||||
|
|
@ -119,9 +212,70 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 });
|
||||
}
|
||||
|
||||
setTool(id) {
|
||||
this.tool = id;
|
||||
for (const [key, btn] of Object.entries(this.toolButtons)) btn.setActive(key === id);
|
||||
/** Which category/option a tool id belongs to — drives both the initial radio-checked/option-selected HTML and (indirectly) which id gets applied when a row's dropdown or dropdown-less radio fires. */
|
||||
findCategoryFor(toolId) {
|
||||
for (const cat of CATEGORIES) {
|
||||
if (!cat.options) { if (cat.id === toolId) return [cat, null]; continue; }
|
||||
if (cat.options.some((o) => o.id === toolId)) return [cat, toolId];
|
||||
}
|
||||
return [CATEGORIES[0], CATEGORIES[0].options[0].id];
|
||||
}
|
||||
|
||||
/**
|
||||
* One radio button per category (mutually exclusive via a shared `name`,
|
||||
* so this is a native HTML radio group — not hand-rolled), a dropdown next
|
||||
* to any category with `options` for its sub-type. Built as a DOM element
|
||||
* (like the Load Level panel below it) rather than Phaser widgets — native
|
||||
* radios/selects are simpler and more robust than reimplementing them in
|
||||
* Graphics. Returns the panel's bottom y so buildToolbar() can lay out the
|
||||
* rest of the column beneath it without hardcoding its (CSS-driven) height.
|
||||
*/
|
||||
buildToolPanel(tx, bw) {
|
||||
const rowH = 34, rowGap = 8, pad = 12, border = 2;
|
||||
const top = 70;
|
||||
const panelH = border * 2 + pad * 2 + CATEGORIES.length * rowH + (CATEGORIES.length - 1) * rowGap;
|
||||
const [activeCat, activeOpt] = this.findCategoryFor(this.tool);
|
||||
const inputCss = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:4px; padding:3px 4px; font-size:13px;';
|
||||
|
||||
const rows = CATEGORIES.map((cat, i) => {
|
||||
const checked = cat.id === activeCat.id ? 'checked' : '';
|
||||
const select = cat.options
|
||||
? `<select data-cat="${cat.id}" style="${inputCss} margin-left:auto; width:112px;">
|
||||
${cat.options.map((o) => `<option value="${o.id}" ${cat.id === activeCat.id && o.id === activeOpt ? 'selected' : ''}>${o.label}</option>`).join('')}
|
||||
</select>`
|
||||
: '';
|
||||
const marginBottom = i === CATEGORIES.length - 1 ? 0 : rowGap;
|
||||
return `
|
||||
<div style="display:flex; align-items:center; height:${rowH}px; margin-bottom:${marginBottom}px;">
|
||||
<input type="radio" name="wf-tool" id="wf-tool-${cat.id}" value="${cat.id}" ${checked}
|
||||
style="accent-color:#c8a84b; width:16px; height:16px; flex:none; cursor:pointer;">
|
||||
<label for="wf-tool-${cat.id}" style="margin-left:8px; width:62px; flex:none; cursor:pointer;">${cat.label}</label>
|
||||
${select}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `width:${bw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex}; font-size:15px;`;
|
||||
el.innerHTML = `<div style="background:#0a0a12ee; border:${border}px solid #c8a84b; border-radius:12px; padding:${pad}px;">${rows}</div>`;
|
||||
this.toolPanelDom = this.add.dom(tx + bw / 2, top + panelH / 2, el);
|
||||
|
||||
el.querySelectorAll('input[name="wf-tool"]').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const cat = CATEGORIES.find((c) => c.id === radio.value);
|
||||
const select = el.querySelector(`select[data-cat="${radio.value}"]`);
|
||||
this.tool = select ? select.value : cat.id;
|
||||
this.draw(); // patrol-route overlay only shows while the Patrol tool is active
|
||||
});
|
||||
});
|
||||
el.querySelectorAll('select[data-cat]').forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
|
||||
this.tool = select.value;
|
||||
this.draw();
|
||||
});
|
||||
});
|
||||
|
||||
return top + panelH;
|
||||
}
|
||||
|
||||
renameLevel() {
|
||||
|
|
@ -130,12 +284,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
|
||||
resizeLevel() {
|
||||
const wStr = window.prompt('Grid width (cells):', String(this.level.width));
|
||||
const wStr = window.prompt(`Grid width (cells, up to ${MAX_GRID}):`, String(this.level.width));
|
||||
if (!wStr) return;
|
||||
const hStr = window.prompt('Grid height (cells):', String(this.level.height));
|
||||
const hStr = window.prompt(`Grid height (cells, up to ${MAX_GRID}):`, String(this.level.height));
|
||||
if (!hStr) return;
|
||||
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, 48);
|
||||
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, 48);
|
||||
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, MAX_GRID);
|
||||
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID);
|
||||
this.pushUndo();
|
||||
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
|
||||
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
|
||||
|
|
@ -150,16 +304,24 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null,
|
||||
exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null,
|
||||
};
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
}
|
||||
|
||||
newLevel() { this.pushUndo(); this.level = this.defaultLevel(); this.metaName.setLabel(`Name: ${this.level.name}`); this.rebuildModel(); }
|
||||
newLevel() {
|
||||
this.pushUndo();
|
||||
this.level = this.defaultLevel();
|
||||
this.selectedEnemy = null;
|
||||
this.metaName.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
}
|
||||
|
||||
// ── Load panel (DOM) ────────────────────────────────────────────────────
|
||||
|
||||
buildLoadPanel() {
|
||||
const rx = BOARD_X + BOARD_SIZE + 90 + 260 + 40;
|
||||
const rw = 420;
|
||||
const rx = this.rpX;
|
||||
const rw = this.rpW;
|
||||
const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;';
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`;
|
||||
|
|
@ -228,33 +390,194 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
playerStart: clone.playerStart ?? null,
|
||||
exit: clone.exit ?? null,
|
||||
};
|
||||
this.selectedEnemy = null;
|
||||
this.metaName?.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
this.flashLoadWarn(`Loaded ${fileName}`);
|
||||
}
|
||||
|
||||
// ── Map overview (minimap) ───────────────────────────────────────────────
|
||||
|
||||
buildMinimap() {
|
||||
const x = this.rpX;
|
||||
const y = 330;
|
||||
const size = MINIMAP_SIZE;
|
||||
this.minimapX = x;
|
||||
this.minimapY = y;
|
||||
this.minimapSize = size;
|
||||
|
||||
this.add.text(x + size / 2, y - 18, 'MAP OVERVIEW', { fontFamily: FONT, fontSize: '16px', color: COLORS.goldHex }).setOrigin(0.5);
|
||||
this.add.rectangle(x + size / 2, y + size / 2, size + 8, size + 8, 0x000000).setStrokeStyle(2, COLORS.accent);
|
||||
|
||||
const key = 'wf-editor-minimap';
|
||||
if (this.textures.exists(key)) this.textures.remove(key);
|
||||
this.minimapTexture = this.textures.createCanvas(key, size, size);
|
||||
this.minimapCtx = this.minimapTexture.context;
|
||||
this.minimapImage = this.add.image(x, y, key).setOrigin(0, 0)
|
||||
.setInteractive(new Phaser.Geom.Rectangle(0, 0, size, size), Phaser.Geom.Rectangle.Contains);
|
||||
this.minimapBox = this.add.graphics();
|
||||
|
||||
this.minimapImage.on('pointerdown', (p) => this.jumpToMinimap(p));
|
||||
this.minimapImage.on('pointermove', (p) => { if (p.isDown) this.jumpToMinimap(p); });
|
||||
|
||||
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',
|
||||
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
|
||||
}
|
||||
|
||||
jumpToMinimap(pointer) {
|
||||
const lx = pointer.x - this.minimapX, ly = pointer.y - this.minimapY;
|
||||
if (lx < 0 || ly < 0 || lx > this.minimapSize || ly > this.minimapSize) return;
|
||||
const cx = (lx / this.minimapSize) * this.level.width;
|
||||
const cy = (ly / this.minimapSize) * this.level.height;
|
||||
this.setView(cx - (BOARD_SIZE / this.zoom) / 2, cy - (BOARD_SIZE / this.zoom) / 2);
|
||||
}
|
||||
|
||||
/** Downsampled whole-grid overview — cost is bounded by minimap resolution, not grid size. Called whenever level data settles. */
|
||||
drawMinimapContent() {
|
||||
if (!this.minimapCtx) return;
|
||||
const size = this.minimapSize;
|
||||
const lvl = this.level;
|
||||
const ctx = this.minimapCtx;
|
||||
const img = ctx.createImageData(size, size);
|
||||
const sx = lvl.width / size, sy = lvl.height / size;
|
||||
for (let py = 0; py < size; py++) {
|
||||
const cy = Math.min(lvl.height - 1, Math.floor(py * sy));
|
||||
const row = lvl.walls[cy];
|
||||
for (let px = 0; px < size; px++) {
|
||||
const cx = Math.min(lvl.width - 1, Math.floor(px * sx));
|
||||
const wt = row[cx];
|
||||
const [r, gr, b] = wt > 0 ? (MINIMAP_WALL_RGB[wt] ?? [136, 136, 136]) : [30, 30, 38];
|
||||
const idx = (py * size + px) * 4;
|
||||
img.data[idx] = r; img.data[idx + 1] = gr; img.data[idx + 2] = b; img.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
this.minimapTexture.refresh();
|
||||
}
|
||||
|
||||
/** Cheap viewport-rectangle overlay — redrawn on every pan/zoom, independent of the (throttled) content redraw. */
|
||||
drawMinimapBox() {
|
||||
if (!this.minimapBox) return;
|
||||
const g = this.minimapBox;
|
||||
g.clear();
|
||||
const size = this.minimapSize;
|
||||
const sx = size / this.level.width, sy = size / this.level.height;
|
||||
const bx = this.minimapX + Phaser.Math.Clamp(this.viewX, 0, this.level.width) * sx;
|
||||
const by = this.minimapY + Phaser.Math.Clamp(this.viewY, 0, this.level.height) * sy;
|
||||
const bw = Math.min(size, (BOARD_SIZE / this.zoom) * sx);
|
||||
const bh = Math.min(size, (BOARD_SIZE / this.zoom) * sy);
|
||||
g.lineStyle(2, 0xffffff, 0.9);
|
||||
g.strokeRect(bx, by, bw, bh);
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
|
||||
bindPointer() {
|
||||
this.input.on('pointerdown', (p) => this.handleClick(p, true));
|
||||
this.input.on('pointermove', (p) => { if (p.isDown) this.handleClick(p, false); });
|
||||
this.input.mouse?.disableContextMenu();
|
||||
this.input.on('pointerdown', (p) => {
|
||||
if (p.rightButtonDown()) { this.panState = { x: p.x, y: p.y, viewX: this.viewX, viewY: this.viewY }; return; }
|
||||
this.handleClick(p, true);
|
||||
});
|
||||
this.input.on('pointermove', (p) => {
|
||||
if (this.panState) {
|
||||
const dx = (p.x - this.panState.x) / this.zoom, dy = (p.y - this.panState.y) / this.zoom;
|
||||
this.setView(this.panState.viewX - dx, this.panState.viewY - dy);
|
||||
return;
|
||||
}
|
||||
if (p.isDown) this.handleClick(p, false);
|
||||
});
|
||||
this.input.on('pointerup', () => {
|
||||
if (this.panState) { this.panState = null; return; }
|
||||
this.flushValidate();
|
||||
});
|
||||
this.input.on('wheel', (p, _over, _dx, dy) => {
|
||||
if (!this.onBoard(p)) return;
|
||||
this.zoomAt(p.x, p.y, dy > 0 ? 0.85 : 1 / 0.85);
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(p, isDown) {
|
||||
if (!this.onBoard(p)) return;
|
||||
const [cx, cy] = this.toCell(p.x, p.y);
|
||||
if (cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height) return;
|
||||
let [cx, cy] = this.toCell(p.x, p.y);
|
||||
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
|
||||
if (!isDown && !paintTools.has(this.tool)) return;
|
||||
|
||||
const outOfRange = cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height;
|
||||
if (outOfRange && this.tool === 'erase') return; // nothing to erase in the void
|
||||
|
||||
// A single click is cheap to fully validate even on a huge grid; a
|
||||
// drag-continuation shares the undo entry pushed at the stroke's start.
|
||||
if (isDown) this.pushUndo();
|
||||
|
||||
if (outOfRange) {
|
||||
if (!this.growToInclude(cx, cy)) return; // refused: would exceed MAX_GRID
|
||||
[cx, cy] = this.toCell(p.x, p.y); // growth shifted viewX/viewY; recompute for the same screen point
|
||||
}
|
||||
|
||||
this.applyTool(cx, cy);
|
||||
this.rebuildModel();
|
||||
if (isDown) {
|
||||
this.rebuildModel();
|
||||
} else {
|
||||
// Mid-drag paint stroke: keep painting responsive (draw is now
|
||||
// viewport-clipped, so it stays cheap regardless of grid size) and let
|
||||
// the reachability check catch up after the stroke settles instead of
|
||||
// running on every pointermove.
|
||||
this.draw();
|
||||
this.scheduleValidate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the grid in whatever direction(s) are needed so cell (cx, cy)
|
||||
* becomes valid, preserving all existing content (walls/doors/entities
|
||||
* shift, never lose data) and re-sealing the new outer edge with border
|
||||
* walls — the same closed-boundary invariant resizeLevel() maintains.
|
||||
* Growing left/up shifts viewX/viewY by the same padding so the on-screen
|
||||
* view doesn't jump. Returns false (no-op) if the grid is already in range
|
||||
* or growth would exceed MAX_GRID.
|
||||
*/
|
||||
growToInclude(cx, cy) {
|
||||
const lvl = this.level;
|
||||
const padLeft = Math.max(0, -cx);
|
||||
const padTop = Math.max(0, -cy);
|
||||
const padRight = Math.max(0, cx - (lvl.width - 1));
|
||||
const padBottom = Math.max(0, cy - (lvl.height - 1));
|
||||
if (!padLeft && !padTop && !padRight && !padBottom) return true;
|
||||
|
||||
const newW = lvl.width + padLeft + padRight;
|
||||
const newH = lvl.height + padTop + padBottom;
|
||||
if (newW > MAX_GRID || newH > MAX_GRID) return false;
|
||||
|
||||
const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => {
|
||||
const oy = y - padTop, ox = x - padLeft;
|
||||
if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox];
|
||||
return 0; // freshly grown territory starts as open floor
|
||||
}));
|
||||
for (let x = 0; x < newW; x++) { walls[0][x] = 1; walls[newH - 1][x] = 1; }
|
||||
for (let y = 0; y < newH; y++) { walls[y][0] = 1; walls[y][newW - 1] = 1; }
|
||||
|
||||
const shift = (e) => ({ ...e, x: e.x + padLeft, y: e.y + padTop });
|
||||
this.level = {
|
||||
...lvl, width: newW, height: newH, walls,
|
||||
doors: lvl.doors.map(shift),
|
||||
enemies: lvl.enemies.map(shift),
|
||||
items: lvl.items.map(shift),
|
||||
playerStart: lvl.playerStart ? shift(lvl.playerStart) : null,
|
||||
exit: lvl.exit ? shift(lvl.exit) : null,
|
||||
};
|
||||
this.viewX += padLeft;
|
||||
this.viewY += padTop;
|
||||
return true;
|
||||
}
|
||||
|
||||
applyTool(x, y) {
|
||||
const lvl = this.level;
|
||||
if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; }
|
||||
if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; }
|
||||
if (this.tool === 'patrol') { this.applyPatrolTool(x, y); return; }
|
||||
|
||||
lvl.walls[y][x] = 0; // every remaining tool places on floor
|
||||
if (this.tool === 'door') {
|
||||
|
|
@ -266,15 +589,42 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
lvl.exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
|
||||
} else if (this.tool === 'enemy') {
|
||||
const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (i >= 0) lvl.enemies.splice(i, 1); else lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
|
||||
if (i >= 0) {
|
||||
if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null;
|
||||
lvl.enemies.splice(i, 1);
|
||||
} else {
|
||||
lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] });
|
||||
}
|
||||
} else if (this.tool === 'ammo' || this.tool === 'health') {
|
||||
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
|
||||
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patrol mode: click a guard to make it the active one for editing (see
|
||||
* `this.selectedEnemy`, an object reference — indices don't survive other
|
||||
* tools splicing the enemies array); once one is active, click any other
|
||||
* cell to append a waypoint at its center, or click an existing waypoint
|
||||
* to remove it. Never touches the walls grid (unlike every other
|
||||
* non-erase tool) — this is metadata editing, not placement.
|
||||
*/
|
||||
applyPatrolTool(x, y) {
|
||||
const lvl = this.level;
|
||||
const clickedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (clickedEnemy) { this.selectedEnemy = clickedEnemy; return; }
|
||||
if (!this.selectedEnemy || !lvl.enemies.includes(this.selectedEnemy)) { this.selectedEnemy = null; return; }
|
||||
|
||||
const enemy = this.selectedEnemy;
|
||||
enemy.patrol = enemy.patrol ?? [];
|
||||
const i = enemy.patrol.findIndex((n) => Math.floor(n.x) === x && Math.floor(n.y) === y);
|
||||
if (i >= 0) enemy.patrol.splice(i, 1); else enemy.patrol.push({ x: x + 0.5, y: y + 0.5 });
|
||||
}
|
||||
|
||||
clearEntitiesAt(x, y) {
|
||||
const lvl = this.level;
|
||||
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
|
||||
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
|
||||
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
|
||||
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
|
||||
|
|
@ -284,6 +634,25 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
bindKeys() {
|
||||
this.input.keyboard.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); });
|
||||
this.input.keyboard.on('keydown-F', () => this.fitToGrid());
|
||||
const boardCenter = () => [BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2];
|
||||
this.input.keyboard.on('keydown-PLUS', () => this.zoomAt(...boardCenter(), 1.25));
|
||||
this.input.keyboard.on('keydown-NUMPAD_ADD', () => this.zoomAt(...boardCenter(), 1.25));
|
||||
this.input.keyboard.on('keydown-MINUS', () => this.zoomAt(...boardCenter(), 0.8));
|
||||
this.input.keyboard.on('keydown-NUMPAD_SUBTRACT', () => this.zoomAt(...boardCenter(), 0.8));
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.wasd = this.input.keyboard.addKeys('W,A,S,D');
|
||||
}
|
||||
|
||||
update(_time, delta) {
|
||||
if (!this.cursors || this.panState) return;
|
||||
const speed = (500 / this.zoom) * (delta / 1000); // ~constant on-screen pan speed at any zoom
|
||||
let dx = 0, dy = 0;
|
||||
if (this.cursors.left.isDown || this.wasd.A.isDown) dx -= speed;
|
||||
if (this.cursors.right.isDown || this.wasd.D.isDown) dx += speed;
|
||||
if (this.cursors.up.isDown || this.wasd.W.isDown) dy -= speed;
|
||||
if (this.cursors.down.isDown || this.wasd.S.isDown) dy += speed;
|
||||
if (dx || dy) this.setView(this.viewX + dx, this.viewY + dy);
|
||||
}
|
||||
|
||||
// ── Undo / model ──────────────────────────────────────────────────────────
|
||||
|
|
@ -296,10 +665,23 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const snap = this.undoStack.pop();
|
||||
if (!snap) return;
|
||||
this.level = JSON.parse(snap);
|
||||
this.selectedEnemy = null; // level was wholesale-replaced; old reference can't match anything in it
|
||||
this.setView(this.viewX, this.viewY); // re-clamp in case the undone edit resized the grid
|
||||
this.rebuildModel();
|
||||
}
|
||||
|
||||
rebuildModel() {
|
||||
rebuildModel() { this.runValidate(); }
|
||||
|
||||
scheduleValidate() {
|
||||
if (this._validateTimer) this._validateTimer.remove(false);
|
||||
this._validateTimer = this.time.delayedCall(150, () => this.runValidate());
|
||||
}
|
||||
|
||||
/** Cancels any pending debounce and validates immediately — call before anything that reads `this.result` (Test Play, Export). */
|
||||
flushValidate() { this.runValidate(); }
|
||||
|
||||
runValidate() {
|
||||
if (this._validateTimer) { this._validateTimer.remove(false); this._validateTimer = null; }
|
||||
try {
|
||||
const model = buildLevelModel(this.level);
|
||||
this.result = validateLevel(model);
|
||||
|
|
@ -308,6 +690,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
this.issueText?.setText(this.result.issues.length ? `⚠ ${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK');
|
||||
this.draw();
|
||||
this.drawMinimapContent();
|
||||
}
|
||||
|
||||
// ── Drawing ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -315,20 +698,22 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
draw() {
|
||||
const g = this.g;
|
||||
g.clear();
|
||||
const k = this.cellPx;
|
||||
const k = this.zoom;
|
||||
const lvl = this.level;
|
||||
const [x0, y0, x1, y1] = this.visibleCellRange();
|
||||
|
||||
for (let y = 0; y < lvl.height; y++) {
|
||||
for (let x = 0; x < lvl.width; x++) {
|
||||
const wt = lvl.walls[y][x];
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const row = lvl.walls[y];
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const wt = row[x];
|
||||
const [bx, by] = this.toBoard(x, y);
|
||||
g.fillStyle(wt > 0 ? (WALL_COLORS[wt] ?? 0x888888) : 0x2a2a32, 1);
|
||||
g.fillRect(bx, by, k - 1, k - 1);
|
||||
}
|
||||
}
|
||||
g.lineStyle(1, 0x000000, 0.3);
|
||||
for (let x = 0; x <= lvl.width; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + lvl.height * k); }
|
||||
for (let y = 0; y <= lvl.height; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + lvl.width * k, by); }
|
||||
for (let x = x0; x <= x1; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + BOARD_SIZE); }
|
||||
for (let y = y0; y <= y1; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + BOARD_SIZE, by); }
|
||||
|
||||
g.fillStyle(0xb08040, 1);
|
||||
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
|
||||
|
|
@ -349,11 +734,50 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
|
||||
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
|
||||
}
|
||||
|
||||
if (this.tool === 'patrol') this.drawPatrolRoutes();
|
||||
|
||||
this.drawMinimapBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enemy with a route gets a dim line; the selected one (see
|
||||
* applyPatrolTool) gets a bright highlighted line plus a ring around the
|
||||
* 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().
|
||||
*/
|
||||
drawPatrolRoutes() {
|
||||
const g = this.g;
|
||||
const lvl = this.level;
|
||||
const k = this.zoom;
|
||||
for (const e of lvl.enemies) {
|
||||
if (!e.patrol || !e.patrol.length) continue;
|
||||
const selected = e === this.selectedEnemy;
|
||||
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++) {
|
||||
const [ax, ay] = this.toBoard(path[i].x, path[i].y);
|
||||
const [bx, by] = this.toBoard(path[i + 1].x, path[i + 1].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);
|
||||
g.fillCircle(nx, ny, k * 0.16);
|
||||
}
|
||||
}
|
||||
if (this.selectedEnemy && lvl.enemies.includes(this.selectedEnemy)) {
|
||||
const [sx, sy] = this.toBoard(this.selectedEnemy.x, this.selectedEnemy.y);
|
||||
g.lineStyle(3, 0xffe066, 1);
|
||||
g.strokeCircle(sx, sy, k * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test play / export ───────────────────────────────────────────────────
|
||||
|
||||
testPlay() {
|
||||
this.flushValidate();
|
||||
if (this.result.issues.length) return;
|
||||
this.registry.set('wolfenstein-editor-state', JSON.parse(JSON.stringify(this.level)));
|
||||
this.scene.start('WolfensteinGame', {
|
||||
|
|
@ -372,6 +796,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
|
||||
exportLevel() {
|
||||
this.flushValidate();
|
||||
if (this.result.issues.length) return;
|
||||
this.download(`level-${this.level.id}.json`, this.level);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
this._mouseFireHeld = false;
|
||||
this._lastAutosave = 0;
|
||||
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC');
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC,SPACE');
|
||||
|
||||
this._bindPointerLock();
|
||||
|
||||
|
|
@ -220,6 +220,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
||||
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.SPACE)) Logic.openNearestDoor(this.state);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- loop
|
||||
|
|
@ -296,12 +297,15 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0);
|
||||
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
|
||||
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
|
||||
// Doors no longer open automatically on approach (see openNearestDoor) —
|
||||
// without this, there's no way to discover that Space is the interact key.
|
||||
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
|
||||
return objs;
|
||||
}
|
||||
|
||||
_setHudVisible(visible) {
|
||||
for (const o of Object.values(this.hud)) o.setVisible(visible);
|
||||
if (visible) this.hud.lockHint.setVisible(!this._locked);
|
||||
if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); }
|
||||
}
|
||||
|
||||
_updateHud() {
|
||||
|
|
@ -311,6 +315,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
const w = this.rules.weaponById[p.weapon];
|
||||
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
|
||||
this.hud.lockHint.setVisible(!this._locked);
|
||||
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
|
||||
}
|
||||
|
||||
_toast(msg) {
|
||||
|
|
|
|||
|
|
@ -24,13 +24,26 @@ export const DOOR_WALL_TYPE = 9;
|
|||
export function createState(level, rules) {
|
||||
const walls = level.walls.map((row) => row.slice());
|
||||
const doors = (level.doors ?? []).map((d, i) => ({
|
||||
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', open: false, timer: 0,
|
||||
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical',
|
||||
// slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
|
||||
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only
|
||||
// stops blocking movement/sight once slide reaches 1 exactly — no
|
||||
// squeezing through a half-open door — so the visual recede (see
|
||||
// WolfensteinView._drawDoorColumn) never has to jump partway through.
|
||||
slide: 0, target: 0, timer: 0,
|
||||
}));
|
||||
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
|
||||
|
||||
const enemies = (level.enemies ?? []).map((e, i) => ({
|
||||
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
|
||||
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false,
|
||||
// Patrol route, editor-authored: an idle (never-yet-alerted) enemy walks
|
||||
// home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever,
|
||||
// until it spots the player (see stepPatrol). homeX/Y is the spawn point
|
||||
// itself, since e.x/y move once patrolling starts.
|
||||
homeX: e.x, homeY: e.y,
|
||||
patrol: (e.patrol ?? []).map((n) => ({ x: n.x, y: n.y })),
|
||||
patrolIndex: 0, patrolDir: 1,
|
||||
}));
|
||||
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
|
||||
|
||||
|
|
@ -165,29 +178,66 @@ function stepPlayer(state, rules) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doors — open on proximity, auto-close once nobody is near
|
||||
// Doors — player must press Space near one to open it (see openNearestDoor,
|
||||
// called from WolfensteinGame on a Space keydown); enemies still shove doors
|
||||
// open on approach, since they have no equivalent of pressing a key. Either
|
||||
// way, once cracked open at all, nobody standing near it lets it finish
|
||||
// closing. A door that reaches full auto-close idle time with the area clear
|
||||
// starts sliding shut again on its own.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNear(state, x, y, radius) {
|
||||
const p = state.player;
|
||||
if (!p.dead && Math.hypot(p.x - x, p.y - y) < radius) return true;
|
||||
for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - x, e.y - y) < radius) return true;
|
||||
const DOOR_SLIDE_MS = 400;
|
||||
const DOOR_RADIUS = 0.9;
|
||||
|
||||
function enemyNearDoor(state, cx, cy) {
|
||||
for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - cx, e.y - cy) < DOOR_RADIUS) return true;
|
||||
return false;
|
||||
}
|
||||
function anyoneNearDoor(state, cx, cy) {
|
||||
const p = state.player;
|
||||
if (!p.dead && Math.hypot(p.x - cx, p.y - cy) < DOOR_RADIUS) return true;
|
||||
return enemyNearDoor(state, cx, cy);
|
||||
}
|
||||
|
||||
function findOpenableDoor(state) {
|
||||
const p = state.player;
|
||||
if (p.dead) return null;
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const d of state.doors) {
|
||||
if (d.target === 1) continue; // already open or opening
|
||||
const dist = Math.hypot(p.x - (d.x + 0.5), p.y - (d.y + 0.5));
|
||||
if (dist <= DOOR_RADIUS + 0.2 && dist < bestDist) { best = d; bestDist = dist; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Player interact (Space): open the nearest closed/closing door in range, if any. */
|
||||
export function openNearestDoor(state) {
|
||||
const door = findOpenableDoor(state);
|
||||
if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); }
|
||||
}
|
||||
|
||||
/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */
|
||||
export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); }
|
||||
|
||||
function stepDoors(state, rules) {
|
||||
for (const d of state.doors) {
|
||||
const near = isNear(state, d.x + 0.5, d.y + 0.5, 0.9);
|
||||
if (near) {
|
||||
if (!d.open) { d.open = true; state.events.push({ t: 'doorOpen', x: d.x, y: d.y }); }
|
||||
d.timer = rules.constants.doorAutoCloseMs;
|
||||
state.map.walls[d.y][d.x] = 0;
|
||||
} else if (d.open) {
|
||||
d.timer -= rules.stepMs;
|
||||
if (d.timer <= 0) {
|
||||
d.open = false;
|
||||
state.map.walls[d.y][d.x] = DOOR_WALL_TYPE;
|
||||
state.events.push({ t: 'doorClose', x: d.x, y: d.y });
|
||||
const cx = d.x + 0.5, cy = d.y + 0.5;
|
||||
if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them
|
||||
if (d.target === 0 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it
|
||||
|
||||
const wasOpen = d.slide >= 1;
|
||||
if (d.target === 1 && d.slide < 1) d.slide = Math.min(1, d.slide + rules.stepMs / DOOR_SLIDE_MS);
|
||||
else if (d.target === 0 && d.slide > 0) d.slide = Math.max(0, d.slide - rules.stepMs / DOOR_SLIDE_MS);
|
||||
const isOpen = d.slide >= 1;
|
||||
if (isOpen !== wasOpen) state.map.walls[d.y][d.x] = isOpen ? 0 : DOOR_WALL_TYPE;
|
||||
|
||||
if (d.target === 1 && d.slide >= 1) {
|
||||
if (!wasOpen || anyoneNearDoor(state, cx, cy)) {
|
||||
d.timer = rules.constants.doorAutoCloseMs;
|
||||
} else {
|
||||
d.timer -= rules.stepMs;
|
||||
if (d.timer <= 0) { d.target = 0; state.events.push({ t: 'doorClose', x: d.x, y: d.y }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +258,8 @@ function stepEnemyAI(state, rules) {
|
|||
const canSee = distToPlayer <= def.detectRange && 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 }); }
|
||||
if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); continue; }
|
||||
stepPatrol(state, rules, e, def);
|
||||
continue;
|
||||
}
|
||||
if (canSee) e.state = 'chase';
|
||||
|
|
@ -240,6 +291,28 @@ function stepEnemyAI(state, rules) {
|
|||
}
|
||||
}
|
||||
|
||||
const PATROL_ARRIVE_DIST = 0.12;
|
||||
|
||||
/** Ping-pong an idle enemy along [home, ...patrol]; a no-op if it has no route. */
|
||||
function stepPatrol(state, rules, e, def) {
|
||||
if (!e.patrol || !e.patrol.length) return;
|
||||
const path = [{ x: e.homeX, y: e.homeY }, ...e.patrol];
|
||||
if (e.patrolIndex >= path.length) e.patrolIndex = path.length - 1;
|
||||
const target = path[e.patrolIndex];
|
||||
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; }
|
||||
e.patrolIndex = next;
|
||||
return;
|
||||
}
|
||||
const mvx = (dx / dist) * def.speed * rules.dt;
|
||||
const mvy = (dy / dist) * def.speed * rules.dt;
|
||||
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
||||
e.angle = Math.atan2(dy, dx);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -497,6 +570,10 @@ export function validateLevel(level) {
|
|||
for (const e of level.enemies ?? []) {
|
||||
const c = { x: Math.floor(e.x), y: Math.floor(e.y) };
|
||||
if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`);
|
||||
for (const n of e.patrol ?? []) {
|
||||
const nc = { x: Math.floor(n.x), y: Math.floor(n.y) };
|
||||
if (level.walls[nc.y]?.[nc.x] > 0) issues.push(`enemy patrol node (${n.x},${n.y}) sits inside a wall cell`);
|
||||
}
|
||||
}
|
||||
for (const it of level.items ?? []) {
|
||||
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
|
||||
|
|
|
|||
|
|
@ -11,23 +11,73 @@
|
|||
// also exactly the parametric "t" of the ray equation P = origin + dir*t,
|
||||
// which is what the swept-bullet and line-of-sight callers rely on (t=1 means
|
||||
// "exactly at the point dir was aimed at").
|
||||
//
|
||||
// Door cells get real recessed-mid-plane geometry (see intersectDoorMidplane)
|
||||
// when a caller opts in via `doorAware` — WolfensteinView's render raycast
|
||||
// does; collision/line-of-sight/bullet callers in WolfensteinLogic don't, so
|
||||
// a door stays a simple full-cell solid for gameplay purposes exactly as
|
||||
// before (see stepDoors' note on why passability only flips at slide===1).
|
||||
// This is a rendering-only enhancement, not a change to what's walkable.
|
||||
|
||||
const DEFAULT_MAX_STEPS = 256;
|
||||
|
||||
// Duplicated from WolfensteinLogic.DOOR_WALL_TYPE rather than imported —
|
||||
// WolfensteinLogic imports FROM this module, so importing back would be
|
||||
// circular. Keep these in sync if either changes.
|
||||
const DOOR_WALL_TYPE = 9;
|
||||
|
||||
/** True outside the grid too, so a ray/AABB check never has to special-case the border. */
|
||||
export function isWallCell(map, cellX, cellY) {
|
||||
if (cellX < 0 || cellY < 0 || cellX >= map.width || cellY >= map.height) return true;
|
||||
return map.walls[cellY][cellX] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-cell intersection test for a door's mid-cell plane — a door sits at
|
||||
* half the cell's depth along its blocking axis, not flush with either
|
||||
* face, so walls either side of it read as thick blocks with the (thinner)
|
||||
* door recessed between them: the "H" shape, door as the crossbar. The
|
||||
* blocking axis is inferred from geometry (which pair of neighbors is
|
||||
* walled off), not the doors[] `orientation` field, which nothing actually
|
||||
* sets to anything but 'vertical'.
|
||||
*
|
||||
* Returns null when the ray's own trajectory would clear the cell sideways
|
||||
* (cross into a neighboring cell) before ever reaching that mid-plane — the
|
||||
* caller should then keep stepping the DDA as if this cell were transparent
|
||||
* rather than treat it as a miss; stepping on lets the DDA reach the actual
|
||||
* flanking wall cell and hit it at its own true (nearer, unrecessed)
|
||||
* distance, which is what makes that wall's inward face visible at grazing
|
||||
* angles — the two verticals of the "H," rendered as ordinary wall hits,
|
||||
* not anything faked here.
|
||||
*/
|
||||
function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY) {
|
||||
const blocksEW = isWallCell(map, cellX, cellY - 1) && isWallCell(map, cellX, cellY + 1);
|
||||
if (blocksEW) {
|
||||
if (Math.abs(dirX) < 1e-9) return null;
|
||||
const t = (cellX + 0.5 - x) / dirX;
|
||||
if (t <= 0) return null;
|
||||
const along = y + t * dirY - cellY;
|
||||
if (along < 0 || along > 1) return null;
|
||||
return { perpDist: t, side: 0, textureX: along };
|
||||
}
|
||||
if (Math.abs(dirY) < 1e-9) return null;
|
||||
const t = (cellY + 0.5 - y) / dirY;
|
||||
if (t <= 0) return null;
|
||||
const along = x + t * dirX - cellX;
|
||||
if (along < 0 || along > 1) return null;
|
||||
return { perpDist: t, side: 1, textureX: along };
|
||||
}
|
||||
|
||||
/**
|
||||
* March a ray from (x,y) along direction (dirX,dirY) — need not be unit
|
||||
* length; distances/`perpDist` come out scaled to that vector's own length —
|
||||
* until it exits the first solid cell. Returns null if it runs off the map
|
||||
* edge or exceeds maxSteps without a hit (should never happen inside a
|
||||
* validated closed level).
|
||||
* validated closed level). `doorAware` opts into recessed door-plane
|
||||
* geometry (see intersectDoorMidplane) instead of treating a door cell as an
|
||||
* ordinary flush solid.
|
||||
*/
|
||||
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
|
||||
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doorAware = false) {
|
||||
let mapX = Math.floor(x);
|
||||
let mapY = Math.floor(y);
|
||||
|
||||
|
|
@ -48,12 +98,18 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
|
|||
|
||||
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
|
||||
const wallType = map.walls[mapY][mapX];
|
||||
if (wallType > 0) {
|
||||
const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
|
||||
let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
|
||||
wallX -= Math.floor(wallX);
|
||||
return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
|
||||
if (wallType === 0) continue;
|
||||
|
||||
if (doorAware && wallType === DOOR_WALL_TYPE) {
|
||||
const doorHit = intersectDoorMidplane(map, x, y, dirX, dirY, mapX, mapY);
|
||||
if (doorHit) return { ...doorHit, mapX, mapY, wallType };
|
||||
continue; // ray grazes past this door cell without reaching its mid-plane
|
||||
}
|
||||
|
||||
const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
|
||||
let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
|
||||
wallX -= Math.floor(wallX);
|
||||
return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -69,8 +125,8 @@ export function makeCamera(x, y, angle, fov) {
|
|||
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
|
||||
}
|
||||
|
||||
/** One castRay per screen column, camera-space (fisheye-free). */
|
||||
export function castColumns(map, camera, numColumns) {
|
||||
/** One castRay per screen column, camera-space (fisheye-free). `doorAware` — see castRay — is what WolfensteinView passes to get recessed door geometry. */
|
||||
export function castColumns(map, camera, numColumns, doorAware = false) {
|
||||
const { x, y, dirX, dirY, planeX, planeY } = camera;
|
||||
const maxSteps = fullMapSteps(map);
|
||||
const out = new Array(numColumns);
|
||||
|
|
@ -78,7 +134,7 @@ export function castColumns(map, camera, numColumns) {
|
|||
const cameraX = (2 * col) / numColumns - 1;
|
||||
const rdx = dirX + planeX * cameraX;
|
||||
const rdy = dirY + planeY * cameraX;
|
||||
out[col] = castRay(map, x, y, rdx, rdy, maxSteps);
|
||||
out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doorAware);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
// .refresh()) rather than a WebGL shader — renderer-agnostic (works under
|
||||
// both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and
|
||||
// conceptually the same canvas-manipulation approach Excitebike and Super
|
||||
// Kart's track pre-rasterizer already use elsewhere in this repo. Flat-shaded
|
||||
// per-column rects for now; when painted wall textures land, the same
|
||||
// per-column loop swaps fillRect for a drawImage source-column stretch — the
|
||||
// architecture doesn't change.
|
||||
// Kart's track pre-rasterizer already use elsewhere in this repo. Per column,
|
||||
// a single source-texel-wide slice of the `wolfenstein-walls` sheet (picked
|
||||
// via the raycaster's `textureX`) is drawImage-stretched to the column's
|
||||
// screen width, then darkened with a 'multiply' composite rect to reproduce
|
||||
// the same side/fog shading flat colors used. Falls back to a flat-shaded
|
||||
// fillRect (the original placeholder look) per wall type whose frame isn't
|
||||
// loaded — lets art land wall-type-by-wall-type instead of all at once.
|
||||
//
|
||||
// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser
|
||||
// Images, positioned via the same camera-space projection math as the walls,
|
||||
|
|
@ -17,11 +20,24 @@
|
|||
|
||||
import { castColumns } from './WolfensteinRaycaster.js';
|
||||
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
|
||||
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
|
||||
|
||||
export const VIEW_W = 1920;
|
||||
export const VIEW_H = 940;
|
||||
export const NUM_COLUMNS = 480;
|
||||
|
||||
// wallType -> frame index in the `wolfenstein-walls` sheet, matching
|
||||
// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors are
|
||||
// never texture-sampled, they get their own recessed-slide treatment in
|
||||
// _drawDoorColumn regardless of whether painted wall art exists.
|
||||
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
|
||||
|
||||
// A dark, unshaded fill for the portion of a door that's already slid past —
|
||||
// meant to read as a shadowed pocket the panel has receded into, not more
|
||||
// wall. Kept flat (no side/fog shading) so it reads as a distinct material,
|
||||
// not just a darker version of the door itself.
|
||||
const DOOR_SOCKET_COLOR = '#141018';
|
||||
|
||||
export default class WolfensteinView {
|
||||
constructor(scene, rules) {
|
||||
this.scene = scene;
|
||||
|
|
@ -32,42 +48,100 @@ export default class WolfensteinView {
|
|||
if (scene.textures.exists(key)) scene.textures.remove(key);
|
||||
this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H);
|
||||
this.ctx = this.canvasTexture.context;
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10);
|
||||
|
||||
this.colWidth = VIEW_W / NUM_COLUMNS;
|
||||
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
|
||||
this.spritePool = new Map();
|
||||
|
||||
this.wallTexture = scene.textures.exists('wolfenstein-walls')
|
||||
? scene.textures.get('wolfenstein-walls') : null;
|
||||
}
|
||||
|
||||
render(state, camera) {
|
||||
this._drawWalls(state.map, camera);
|
||||
this._drawWalls(state.map, state.doors, camera);
|
||||
this._drawSprites(state, camera);
|
||||
}
|
||||
|
||||
_drawWalls(map, camera) {
|
||||
_drawWalls(map, doors, camera) {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#2b2b2b';
|
||||
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
|
||||
ctx.fillStyle = '#4a4a4a';
|
||||
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2);
|
||||
|
||||
const cols = castColumns(map, camera, NUM_COLUMNS);
|
||||
const cols = castColumns(map, camera, NUM_COLUMNS, true);
|
||||
for (let i = 0; i < NUM_COLUMNS; i++) {
|
||||
const hit = cols[i];
|
||||
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
|
||||
if (!hit) continue;
|
||||
const lineHeight = VIEW_H / hit.perpDist;
|
||||
let drawStart = -lineHeight / 2 + VIEW_H / 2; if (drawStart < 0) drawStart = 0;
|
||||
let drawEnd = lineHeight / 2 + VIEW_H / 2; if (drawEnd > VIEW_H) drawEnd = VIEW_H;
|
||||
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
|
||||
ctx.fillStyle = shadeColor(base, hit);
|
||||
const rawStart = -lineHeight / 2 + VIEW_H / 2;
|
||||
const rawEnd = lineHeight / 2 + VIEW_H / 2;
|
||||
const drawStart = Math.max(0, rawStart);
|
||||
const drawEnd = Math.min(VIEW_H, rawEnd);
|
||||
if (drawEnd <= drawStart) continue;
|
||||
const x = Math.floor(i * this.colWidth);
|
||||
const w = Math.ceil(this.colWidth) + 1;
|
||||
ctx.fillRect(x, Math.floor(drawStart), w, Math.ceil(drawEnd - drawStart));
|
||||
const y = Math.floor(drawStart);
|
||||
const h = Math.ceil(drawEnd - drawStart);
|
||||
|
||||
if (hit.wallType === DOOR_WALL_TYPE) {
|
||||
this._drawDoorColumn(ctx, x, y, w, h, hit, doors);
|
||||
continue;
|
||||
}
|
||||
|
||||
const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]];
|
||||
if (frame) {
|
||||
const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width));
|
||||
// rawStart/rawEnd (not drawStart/drawEnd) are the true, unclipped
|
||||
// extent of this column — up close that's far taller than the
|
||||
// screen. Crop the source by the same fraction that got clipped off
|
||||
// the destination so the vertical zoom keeps pace with the
|
||||
// horizontal one instead of freezing once the wall overflows VIEW_H.
|
||||
const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height;
|
||||
const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height;
|
||||
ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h);
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.fillStyle = shadeGrey(hit);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
} else {
|
||||
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
|
||||
ctx.fillStyle = shadeColor(base, hit);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
this.canvasTexture.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* hit.perpDist already IS the recessed mid-plane distance here — the
|
||||
* render raycast opts into door-aware geometry (see castColumns(...,
|
||||
* true) above and WolfensteinRaycaster's intersectDoorMidplane), so this
|
||||
* column's (x,y,w,h), computed by the caller exactly like any other wall,
|
||||
* is already correctly smaller/farther than a flush wall would be. A
|
||||
* grazing column whose ray clears the door cell sideways instead hits the
|
||||
* real flanking wall cell at ITS true distance via the normal wall path
|
||||
* above — that's what makes the wall's inward face visible next to the
|
||||
* door, the two verticals of the "H." Nothing extra to fake here.
|
||||
*
|
||||
* `textureX` is where this column's ray crosses the door's face (0..1
|
||||
* along it, computed at the mid-plane); a door slides such that the low-
|
||||
* textureX edge stays put and the far edge recedes first, so as `slide`
|
||||
* climbs, columns flip from "still there" to "already receded" in
|
||||
* textureX order — the door visibly sliding sideways into its socket.
|
||||
*/
|
||||
_drawDoorColumn(ctx, x, y, w, h, hit, doors) {
|
||||
const door = doors.find((d) => d.x === hit.mapX && d.y === hit.mapY);
|
||||
const visibleFrac = 1 - (door?.slide ?? 0);
|
||||
ctx.fillStyle = hit.textureX < visibleFrac
|
||||
? shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit)
|
||||
: DOOR_SOCKET_COLOR;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
|
||||
_drawSprites(state, camera) {
|
||||
const live = new Set();
|
||||
const sprites = [];
|
||||
|
|
@ -132,11 +206,23 @@ export default class WolfensteinView {
|
|||
}
|
||||
}
|
||||
|
||||
function shadeColor(hex, hit) {
|
||||
let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
|
||||
function shadeMul(hit) {
|
||||
const sideMul = hit.side === 1 ? 0.72 : 1;
|
||||
const fog = Math.max(0.28, 1 - hit.perpDist / 14);
|
||||
const m = sideMul * fog;
|
||||
return sideMul * fog;
|
||||
}
|
||||
|
||||
function shadeColor(hex, hit) {
|
||||
let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
|
||||
const m = shadeMul(hit);
|
||||
r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// A solid grey drawn with 'multiply' compositing scales every channel of
|
||||
// whatever's underneath by the same factor shadeColor() applies to a flat
|
||||
// fill — the same side/fog darkening, applied to a drawn texture instead.
|
||||
function shadeGrey(hit) {
|
||||
const v = Math.round(255 * shadeMul(hit));
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,30 +7,118 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
|
|||
|
||||
## Wall textures — `sheets.walls`
|
||||
|
||||
A horizontal spritesheet, one 64×64 frame per wall type, in the order used by
|
||||
`WolfensteinArt.WALL_COLORS`: frame 0 = type 1 (stone), frame 1 = type 2
|
||||
(wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). A door
|
||||
(closed) reuses frame index matching `DOOR_WALL_TYPE`'s placeholder color
|
||||
(tan) — add a 5th frame if a distinct door texture is wanted.
|
||||
Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
|
||||
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom
|
||||
— a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames,
|
||||
one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion
|
||||
order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
|
||||
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
|
||||
are **not** part of this sheet at all — `WolfensteinView._drawDoorColumn`
|
||||
(as of 2026-08-21) handles type 9 as a special case before the texture path
|
||||
even runs, rendering the flat placeholder tan color (`WALL_COLORS[9]`) for
|
||||
whatever fraction of the door hasn't yet slid open, and a flat dark "socket"
|
||||
fill for the rest (see the sliding-door doc below) — so a 5th sheet frame
|
||||
wouldn't currently be read even if added. Painting a real door texture means
|
||||
teaching `_drawDoorColumn` to sample a frame the same way the wall path
|
||||
does, not just adding a 320×64 sheet.
|
||||
|
||||
### Sliding, recessed doors
|
||||
|
||||
Doors animate open/closed (`WolfensteinLogic.js`'s `stepDoors`, `slide` field
|
||||
0→1 over `DOOR_SLIDE_MS`) rather than popping instantly, and the cell stays
|
||||
solid for *gameplay* (collision/LOS/bullets) through the entire animation —
|
||||
it only becomes passable once `slide` reaches exactly 1 (see stepDoors'
|
||||
comment for why). That's unrelated to the visual, though: doors also render
|
||||
recessed to the middle of the wall's depth — real geometry, not a flat-color
|
||||
trick — via `WolfensteinRaycaster.js`'s `intersectDoorMidplane`, which
|
||||
`WolfensteinView`'s render-only raycast opts into with `castColumns(...,
|
||||
true)` (the `doorAware` flag; every gameplay raycast in `WolfensteinLogic.js`
|
||||
omits it and still sees a door as an ordinary flush full-cell solid, so this
|
||||
never touches what's actually walkable/shootable).
|
||||
|
||||
A door's blocking axis is inferred from geometry (which pair of neighbor
|
||||
cells is walled off) rather than the `doors[]` `orientation` field, which
|
||||
nothing in the codebase actually sets to anything but `'vertical'`. Given
|
||||
that axis, the door plane sits at the cell's exact midpoint (e.g. `x =
|
||||
doorCellX + 0.5` for a door blocking east-west travel) instead of at the
|
||||
near face like a normal wall — a straight-on ray is `perpDist`-recessed by
|
||||
0.5 relative to where a flush wall would sit. Critically, a ray whose
|
||||
trajectory would clear the door cell sideways (cross into a neighboring
|
||||
cell) *before* reaching that mid-plane isn't given a fake hit at all —
|
||||
`castRay` just keeps stepping the DDA, so the ray goes on to hit the actual
|
||||
flanking wall cell at *its own* true, unrecessed distance. That's what
|
||||
produces the "H" shape: the door is the crossbar, visibly set back between
|
||||
two verticals that are ordinary wall hits, not anything faked in
|
||||
`_drawDoorColumn` — confirmed with a sweep of rays across a doorway (see
|
||||
git history around 2026-08-21 for the script) showing a symmetric run of
|
||||
door hits at the recessed distance, flanked on both sides by wall hits at
|
||||
their own, nearer distances, with no discontinuity at the transition.
|
||||
|
||||
`_drawDoorColumn` itself is now simple: `hit.perpDist`/`(x,y,w,h)` already
|
||||
reflect the recessed mid-plane (computed by the caller exactly like any
|
||||
other wall), so it only has to pick door-texture-tan vs. dark socket color
|
||||
per column, using `hit.textureX` (now measured at the mid-plane, 0..1 along
|
||||
the door's face) against `1 - slide` — the same left-to-right slide-open
|
||||
wipe as before, just riding on correct geometry instead of a projection
|
||||
trick.
|
||||
|
||||
**Now wired up** (as of 2026-08-21): `WolfensteinView._drawWalls` reads
|
||||
`hit.textureX` (the raycaster's fractional wall-face position) and
|
||||
`drawImage`s a 1px-wide source-texel column, stretched to the destination
|
||||
column's screen width, then darkens it with a `'multiply'`-composited grey
|
||||
rect using the same side/fog factor the flat-color fallback uses — matches
|
||||
the shading, just applied to a texture instead of a solid fill. Falls back
|
||||
to the old flat-shaded `fillRect` per wall type whose frame isn't loaded
|
||||
(`this.wallTexture` missing, or no `WALL_FRAME` entry for that type — true
|
||||
for type 9/doors today), so art can land wall-type-by-wall-type. The texture
|
||||
is grabbed once in the constructor (`scene.textures.get('wolfenstein-walls')`),
|
||||
which the manifest loader has already populated by the time a game scene is
|
||||
entered — no per-frame lookup cost.
|
||||
|
||||
## Guard enemy — `sheets.guard`
|
||||
|
||||
128×128 frames. Minimum for parity with the current placeholder: one idle
|
||||
pose. For proper directional billboarding later: 8 facing angles × {idle,
|
||||
walk (2-4 frames), shoot, die} — not required for the MVP renderer, which
|
||||
only ever shows a single billboard sprite per enemy.
|
||||
Per the artwork JSON: `frameWidth: 128, frameHeight: 128`. **Minimum useful
|
||||
size: a single 128×128 PNG (1 frame)** — that's all the current renderer can
|
||||
show. `WolfensteinView._drawSprites` calls `img.setTexture('wolf-guard')`
|
||||
with no frame index, so Phaser always displays frame 0 (top-left) of
|
||||
whatever sheet is loaded under that key; there is no facing/animation frame
|
||||
selection logic anywhere in the code yet. Painting extra frames into the
|
||||
sheet is harmless (they'll just sit unused) but won't animate or turn to
|
||||
face the player until that selection logic is written.
|
||||
|
||||
If/when directional billboarding is worth building, the natural layout to
|
||||
target is a grid, frame index = `row * cols + col` in Phaser's row-major
|
||||
order: **8 rows (one per 45° facing angle, starting from "facing camera" and
|
||||
going clockwise) × N columns** for whatever animation set is wanted per
|
||||
angle, e.g. `{idle, walk1, walk2, shoot, die1, die2}` (6 cols → a 768×1024
|
||||
sheet). That's a proposal, not a spec anything reads — pick a smaller set
|
||||
(e.g. idle + walk + shoot only, 3 cols) if painting 48 frames is too much
|
||||
up front, since nothing currently depends on the exact column count.
|
||||
|
||||
## Standalone images — `artwork[]`
|
||||
|
||||
These are loaded as plain images (no frame slicing), so any resolution
|
||||
works, but matching the placeholder's proportions keeps in-world scale
|
||||
consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
|
||||
is applied on top, so a differently-sized source image just gets stretched —
|
||||
same aspect ratio is what matters most):
|
||||
|
||||
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
|
||||
— pickup icons, square, transparent background.
|
||||
- `wolfenstein-bullet` — small bright sprite for a pistol round in flight.
|
||||
- `wolfenstein-muzzle` — muzzle-flash flash sprite (currently unused by the
|
||||
scene beyond the painted placeholder texture; wire up on `weaponFired`
|
||||
events once sfx/vfx pass lands).
|
||||
- `wolfenstein-title` — optional main-menu backdrop art (the menu currently
|
||||
renders a flat panel with title text, matching `TAScreens.js`'s style
|
||||
before `ta-background` was painted).
|
||||
— 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-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
|
||||
once a sfx/vfx pass lands.
|
||||
- `wolfenstein-title` — optional main-menu backdrop art. Not read anywhere
|
||||
in `WolfensteinScreens.js` today; the menu renders a flat panel + title
|
||||
text only (`backdrop()`'s `GAME_WIDTH`×`GAME_HEIGHT` rectangle, matching
|
||||
`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.
|
||||
|
||||
## Sound effects
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue