first commit

This commit is contained in:
Brian Fertig 2026-06-30 22:54:04 -06:00
commit 8565ed34ec
22 changed files with 1844 additions and 0 deletions

143
README.md Normal file
View File

@ -0,0 +1,143 @@
# Escape
A Wolfenstein-3D-style raycaster game built with **Phaser 4**. The "3D" is an
authentic raycasting engine: a 2D grid rendered as vertical wall strips.
Graphics are intentionally stubbed (solid colors / placeholder shapes) so you
can drop in your own spritesheets later.
## Run
There is **no build step and no npm dependency**. The game is plain ES-module
JavaScript; Phaser ships vendored in `vendor/`. Serve the folder with any static
HTTP server and open it in a modern browser (Chrome/Edge/Firefox/Safari — needs
[import map](https://caniuse.com/import-maps) support, ~2023+).
```bash
./start_web.sh # python3 -m http.server 3000
# or any equivalent:
# npx serve .
# php -S localhost:3000
# (drop the folder behind nginx / Apache / GitHub Pages / S3 / …)
```
Then open:
- Game: http://localhost:3000/
- Editor: http://localhost:3000/editor.html
> Opening the files via `file://` will **not** work — ES modules require an HTTP
> origin. Any static server (above) is enough; no server-side code runs.
## Game controls
| Action | Keys |
| ----------------- | ------------------------------------- |
| Move / strafe | `W` `A` `S` `D` (arrows also move) |
| Turn | `←` `→`, or **mouse** (click to lock) |
| Shoot | **Left-click** (mouse locked) / `Space` |
The HUD shows health, ammo, and score. Walk over pickups to collect them; shoot
guards to score. Click the view once to capture the mouse for look controls.
## Level editor (`/editor.html`)
- **Palette** (top): pick a wall tile, the player **Start** marker, or an entity.
- **Left-click / drag**: paint the selected brush.
- **Right-click / drag**: erase (clears the tile and any entity in the cell).
- **Arrow keys**: scroll the view. **Middle-drag**: pan. **Mouse wheel**: zoom.
- **New**: start a fresh blank grid (default 72×72; see `NEW_LEVEL_WIDTH/HEIGHT`
in `src/engine/constants.js`). **Save**: persist to browser storage.
**Export/Import JSON**: files on disk.
- **Play Test**: opens the game in a new tab running your current level.
The grid is large enough to build big levels; only the on-screen cells are
drawn, so bigger maps stay fast. Scroll with the arrow keys as your level grows
past the visible area.
Place an **Exit** entity in a level so the player can finish it; walking onto the
exit advances to the next level in the campaign (see below). A level with no exit
instead completes when every guard is defeated.
## Level file format
Levels are plain JSON — exactly what the editor's **Export JSON** writes and what
the game reads. To author levels, paint one in the editor, Export it, and drop
the file in `src/levels/` (see the campaign section). The format is:
```jsonc
{
"version": 1, // schema version (stamped on export)
"name": "Dungeon Entrance", // display name; also the export filename
"width": 16, // grid columns
"height": 16, // grid rows
"tiles": [ /* … */ ], // row-major ints, length MUST equal width*height
// 0 = floor; 1..5 = wall tiles (constants.js TILES)
"playerStart": { "x": 2.5, "y": 3.5, "dir": 0 }, // cell units; dir in radians
"entities": [ // may be empty
{ "type": "enemy", "x": 8.5, "y": 5.5 },
{ "type": "ammo", "x": 4.5, "y": 4.5 },
{ "type": "health", "x": 6.5, "y": 9.5 },
{ "type": "treasure", "x": 12.5, "y": 2.5 },
{ "type": "exit", "x": 14.5, "y": 8.5 }
]
}
```
`entity.type` and tile ids come from the registries in `src/engine/constants.js`.
Files are validated on load (`parseLevel`): `tiles.length` must equal
`width*height` and `playerStart` must be present; unknown tile/entity ids fail
safe rather than crashing.
## Campaign — using your files as game levels
The game plays the level files listed, in order, in the manifest
`src/levels/levels.json`:
```jsonc
{
"levels": ["level1.json", "level2.json"]
}
```
To add a level: drop its `.json` in `src/levels/` and add its filename to the
`levels` array. Reaching an **Exit** advances to the next file; after the last
one the player sees a victory screen. Health, ammo, and score carry across
levels. If the manifest can't be fetched, the game falls back to the bundled
`level1.data.js`.
## Project layout
```
index.html Game page (import map → vendored Phaser)
editor.html Editor page (import map → vendored Phaser)
vendor/ phaser.esm.min.js (vendored so no npm install is needed)
src/
engine/ Shared core: constants (TILE & entity registries), GameMap,
Raycaster (DDA + ZBuffer), SpriteRenderer, Player, Entity, levelIO
game/ Phaser bootstrap + Boot/Game/Hud scenes
editor/ Phaser bootstrap + EditorScene
levels/ levels.json (campaign manifest), level1.json / level2.json (levels),
level1.data.js (bundled offline fallback, kept in sync with level1.json)
```
The game and editor are separate HTML entry points that share everything in
`src/engine`, so a level you paint is exactly what plays. Native ES modules mean
every relative import includes its `.js` extension, and the bare `phaser`
specifier is resolved by the `<script type="importmap">` block in each HTML file.
## Updating Phaser
`vendor/phaser.esm.min.js` is a copy of Phaser's official ESM build. To update,
download the matching `dist/phaser.esm.min.js` from the
[Phaser release](https://github.com/phaserjs/phaser/releases) (or `npm pack
phaser`) and replace the file — no other changes needed.
## Where to extend next
- **Textures**: swap the solid-color strip in `engine/Raycaster.js` and the
billboard `fillRect` in `engine/SpriteRenderer.js` for `drawImage` of your
spritesheet frames. Tile ids already map 1:1 to a texture index.
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack).
- **Doors / keys**: tile id 5 is flagged `isDoor` in `engine/constants.js`;
wire sliding + locked-door logic into `GameMap`/`Raycaster`.
- **Sound, multiple levels, menus**: hook into the scenes in `src/game`.

82
editor.html Normal file
View File

@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Escape — Level Editor</title>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: #1b1b1f;
overflow: hidden;
font-family: system-ui, sans-serif;
color: #e6e6e6;
}
#editor {
position: absolute;
inset: 0;
}
/* Toolbar overlaid on top of the Phaser editor canvas. */
#toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background: rgba(20, 20, 24, 0.92);
border-bottom: 1px solid #3a3a42;
z-index: 10;
flex-wrap: wrap;
}
#toolbar button {
background: #2d2d36;
color: #e6e6e6;
border: 1px solid #45454f;
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
}
#toolbar button:hover {
background: #3a3a46;
}
#toolbar .spacer {
flex: 1;
}
#status {
font-size: 12px;
color: #9aa0a6;
}
</style>
</head>
<body>
<div id="toolbar">
<strong>Escape Editor</strong>
<button id="btn-new">New</button>
<button id="btn-save">Save</button>
<button id="btn-export">Export JSON</button>
<button id="btn-import">Import JSON</button>
<button id="btn-playtest">▶ Play Test</button>
<span class="spacer"></span>
<span id="status"></span>
</div>
<div id="editor"></div>
<input id="file-input" type="file" accept="application/json" style="display: none" />
<!-- Resolve the bare "phaser" import to the vendored ESM build so no
bundler or npm install is needed — any static file server works. -->
<script type="importmap">
{
"imports": {
"phaser": "./vendor/phaser.esm.min.js"
}
}
</script>
<script type="module" src="./src/editor/main.js"></script>
</body>
</html>

39
index.html Normal file
View File

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Escape</title>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: #000;
overflow: hidden;
}
#game {
width: 100vw;
height: 100vh;
}
/* Crisp upscaling for the low-res raycast view. */
canvas {
image-rendering: pixelated;
}
</style>
</head>
<body>
<div id="game"></div>
<!-- Resolve the bare "phaser" import to the vendored ESM build so no
bundler or npm install is needed — any static file server works. -->
<script type="importmap">
{
"imports": {
"phaser": "./vendor/phaser.esm.min.js"
}
}
</script>
<script type="module" src="./src/game/main.js"></script>
</body>
</html>

338
src/editor/EditorScene.js Normal file
View File

@ -0,0 +1,338 @@
import Phaser from 'phaser';
import { GameMap } from '../engine/GameMap.js';
import {
TILES,
ENTITIES,
MAX_TILE_ID,
getTile,
getEntityDef,
} from '../engine/constants.js';
import {
loadEditorLevel,
saveEditorLevel,
exportLevel,
importLevel,
playTest,
createBlankLevel,
} from '../engine/levelIO.js';
const TOP_MARGIN = 56; // clears the DOM toolbar
const MIN_CELL = 12;
const MAX_CELL = 64;
const SCROLL_SPEED = 900; // px/sec for arrow-key scrolling
const PAN_MARGIN = 120; // keep at least this much grid on screen when scrolling
/** Convert 0xRRGGBB to a CSS hex string for DOM swatches. */
function cssColor(color) {
return '#' + color.toString(16).padStart(6, '0');
}
/**
* Top-down grid level editor. Shares the map format + registries with the game
* (GameMap, TILES, ENTITIES) so what you paint is exactly what plays. Paint
* with left-click, erase with right-click, pan with middle-drag, zoom with the
* wheel. Save / Export / Import / Play Test are wired to the DOM toolbar.
*/
export class EditorScene extends Phaser.Scene {
constructor() {
super('EditorScene');
// Brush is one of:
// { kind: 'tile', id } | { kind: 'start' } | { kind: 'entity', type }
this.brush = { kind: 'tile', id: 1 };
this.cellSize = 32;
this.offsetX = 40;
this.offsetY = TOP_MARGIN + 20;
}
create() {
this.level = loadEditorLevel();
this.map = new GameMap(this.level);
this.gfx = this.add.graphics();
this.statusEl = document.getElementById('status');
this.input.mouse?.disableContextMenu();
this.buildPalette();
this.wireToolbar();
this.setupInput();
this.setStatus('Arrow keys scroll · middle-drag pans · wheel zooms.');
}
// --- DOM palette + toolbar ------------------------------------------------
buildPalette() {
const toolbar = document.getElementById('toolbar');
const palette = document.createElement('div');
palette.style.cssText =
'position:absolute;top:44px;left:0;right:0;display:flex;gap:4px;' +
'flex-wrap:wrap;padding:6px 12px;background:rgba(20,20,24,0.92);' +
'border-bottom:1px solid #3a3a42;z-index:9;';
toolbar.after(palette);
const buttons = [];
const select = (btn, brush) => {
this.brush = brush;
for (const b of buttons) b.style.outline = 'none';
btn.style.outline = '2px solid #ffe066';
};
const swatch = (label, color, brush) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText =
`background:${cssColor(color)};color:#000;border:1px solid #000;` +
'border-radius:3px;padding:4px 8px;cursor:pointer;font-size:12px;';
btn.onclick = () => select(btn, brush);
palette.appendChild(btn);
buttons.push(btn);
return btn;
};
// Empty / eraser tile.
const eraser = swatch('Empty', 0x202024, { kind: 'tile', id: 0 });
eraser.style.color = '#ccc';
// Wall tiles.
for (let id = 1; id <= MAX_TILE_ID; id++) {
const t = TILES[id];
swatch(t.name, t.color, { kind: 'tile', id });
}
// Player start.
const start = swatch('Start', 0x00d0ff, { kind: 'start' });
start.style.color = '#000';
// Entities.
for (const def of Object.values(ENTITIES)) {
swatch(def.name, def.color, { kind: 'entity', type: def.type });
}
select(buttons[1], { kind: 'tile', id: 1 }); // default to first wall
}
wireToolbar() {
const byId = (id) => document.getElementById(id);
byId('btn-new').addEventListener('click', () => {
if (!window.confirm('Start a new blank level? Unsaved changes will be lost.')) {
return;
}
this.level = createBlankLevel();
this.map = new GameMap(this.level);
this.offsetX = 40;
this.offsetY = TOP_MARGIN + 20;
this.setStatus(`New ${this.level.width}×${this.level.height} level.`);
});
byId('btn-save').addEventListener('click', () => {
this.syncLevel();
saveEditorLevel(this.level);
this.setStatus('Saved to browser storage.');
});
byId('btn-export').addEventListener('click', () => {
this.syncLevel();
exportLevel(this.level);
});
byId('btn-playtest').addEventListener('click', () => {
this.syncLevel();
playTest(this.level);
});
const fileInput = byId('file-input');
byId('btn-import').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
try {
this.level = await importLevel(file);
this.map = new GameMap(this.level);
this.setStatus(`Imported "${this.level.name}".`);
} catch {
this.setStatus('Import failed: invalid level file.');
}
fileInput.value = '';
});
}
/** Pull edited tiles back into the LevelData before save/export/playtest. */
syncLevel() {
this.level.tiles = this.map.getTiles();
}
setStatus(msg) {
this.statusEl.textContent = msg;
}
// --- Pointer input --------------------------------------------------------
setupInput() {
// Arrow keys scroll the view for levels bigger than the window. Capture them
// so they don't also scroll the page.
this.cursors = this.input.keyboard.createCursorKeys();
this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT');
this.input.on('pointerdown', (p) => this.paintAt(p));
this.input.on('pointermove', (p) => {
if (p.middleButtonDown()) {
this.offsetX += p.x - p.prevPosition.x;
this.offsetY += p.y - p.prevPosition.y;
} else if (p.leftButtonDown() || p.rightButtonDown()) {
this.paintAt(p);
}
this.updateHoverStatus(p);
});
this.input.on('wheel', (_p, _o, _dx, dy) => {
const next = Phaser.Math.Clamp(
this.cellSize - Math.sign(dy) * 2,
MIN_CELL,
MAX_CELL,
);
this.cellSize = next;
});
}
cellUnder(p) {
const cx = Math.floor((p.x - this.offsetX) / this.cellSize);
const cy = Math.floor((p.y - this.offsetY) / this.cellSize);
if (!this.map.inBounds(cx, cy)) return null;
return { x: cx, y: cy };
}
paintAt(p) {
const cell = this.cellUnder(p);
if (!cell) return;
// Right button always erases (tile + any entity in the cell).
if (p.rightButtonDown()) {
this.map.setTile(cell.x, cell.y, 0);
this.removeEntityAt(cell.x, cell.y);
return;
}
switch (this.brush.kind) {
case 'tile':
this.map.setTile(cell.x, cell.y, this.brush.id);
break;
case 'start':
this.level.playerStart.x = cell.x + 0.5;
this.level.playerStart.y = cell.y + 0.5;
break;
case 'entity':
this.removeEntityAt(cell.x, cell.y);
this.level.entities.push({
type: this.brush.type,
x: cell.x + 0.5,
y: cell.y + 0.5,
});
break;
}
}
removeEntityAt(cx, cy) {
this.level.entities = this.level.entities.filter(
(e) => Math.floor(e.x) !== cx || Math.floor(e.y) !== cy,
);
}
updateHoverStatus(p) {
const cell = this.cellUnder(p);
const brushLabel =
this.brush.kind === 'tile'
? `tile:${getTile(this.brush.id).name}`
: this.brush.kind === 'start'
? 'player start'
: `entity:${this.brush.type}`;
this.setStatus(
cell ? `Cell ${cell.x},${cell.y} | Brush: ${brushLabel}` : `Brush: ${brushLabel}`,
);
}
// --- Scrolling ------------------------------------------------------------
/** Arrow keys scroll the view; pressing right reveals cells to the right. */
handleScroll(delta) {
if (!this.cursors) return;
const step = SCROLL_SPEED * (delta / 1000);
if (this.cursors.left.isDown) this.offsetX += step;
if (this.cursors.right.isDown) this.offsetX -= step;
if (this.cursors.up.isDown) this.offsetY += step;
if (this.cursors.down.isDown) this.offsetY -= step;
}
/** Keep at least PAN_MARGIN of the grid on screen so it can't be lost. */
clampOffsets() {
const viewW = this.scale.width;
const viewH = this.scale.height;
const gridW = this.map.width * this.cellSize;
const gridH = this.map.height * this.cellSize;
const hiX = PAN_MARGIN;
const loX = Math.min(hiX, viewW - gridW - PAN_MARGIN);
this.offsetX = Phaser.Math.Clamp(this.offsetX, loX, hiX);
const hiY = TOP_MARGIN + 20;
const loY = Math.min(hiY, viewH - gridH - PAN_MARGIN);
this.offsetY = Phaser.Math.Clamp(this.offsetY, loY, hiY);
}
// --- Rendering ------------------------------------------------------------
update(_time, delta) {
this.handleScroll(delta);
this.clampOffsets();
const g = this.gfx;
g.clear();
const cs = this.cellSize;
const ox = this.offsetX;
const oy = this.offsetY;
// Only draw the cells currently on screen so huge grids stay fast.
const minX = Math.max(0, Math.floor((0 - ox) / cs));
const maxX = Math.min(this.map.width - 1, Math.floor((this.scale.width - ox) / cs));
const minY = Math.max(0, Math.floor((TOP_MARGIN - oy) / cs));
const maxY = Math.min(this.map.height - 1, Math.floor((this.scale.height - oy) / cs));
// Tiles.
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const id = this.map.tileAt(x, y);
const color = id === 0 ? 0x26262c : getTile(id).color;
g.fillStyle(color, 1);
g.fillRect(ox + x * cs, oy + y * cs, cs - 1, cs - 1);
}
}
// Grid lines (only across the visible cell range).
g.lineStyle(1, 0x000000, 0.35);
const gx0 = oy + minY * cs;
const gx1 = oy + (maxY + 1) * cs;
for (let x = minX; x <= maxX + 1; x++) {
g.lineBetween(ox + x * cs, gx0, ox + x * cs, gx1);
}
const gy0 = ox + minX * cs;
const gy1 = ox + (maxX + 1) * cs;
for (let y = minY; y <= maxY + 1; y++) {
g.lineBetween(gy0, oy + y * cs, gy1, oy + y * cs);
}
// Entities (colored dots), skipping any scrolled off screen.
for (const e of this.level.entities) {
if (e.x < minX - 1 || e.x > maxX + 1 || e.y < minY - 1 || e.y > maxY + 1) continue;
const def = getEntityDef(e.type);
g.fillStyle(def?.color ?? 0xff00ff, 1);
g.fillCircle(ox + e.x * cs, oy + e.y * cs, Math.max(3, cs * 0.28));
}
// Player start (blue arrow in facing direction).
const ps = this.level.playerStart;
if (ps.x >= minX - 1 && ps.x <= maxX + 1 && ps.y >= minY - 1 && ps.y <= maxY + 1) {
const sx = ox + ps.x * cs;
const sy = oy + ps.y * cs;
const d = ps.dir;
const r = cs * 0.4;
g.fillStyle(0x00d0ff, 1);
g.fillTriangle(
sx + Math.cos(d) * r,
sy + Math.sin(d) * r,
sx + Math.cos(d + 2.5) * r,
sy + Math.sin(d + 2.5) * r,
sx + Math.cos(d - 2.5) * r,
sy + Math.sin(d - 2.5) * r,
);
}
}
}

18
src/editor/main.js Normal file
View File

@ -0,0 +1,18 @@
import Phaser from 'phaser';
import { EditorScene } from './EditorScene.js';
// The editor fills the window and redraws a top-down grid. RESIZE scale mode
// lets it follow the browser size; the toolbar is plain DOM in editor.html.
const config = {
type: Phaser.AUTO,
parent: 'editor',
backgroundColor: '#1b1b1f',
scale: {
mode: Phaser.Scale.RESIZE,
width: window.innerWidth,
height: window.innerHeight,
},
scene: [EditorScene],
};
new Phaser.Game(config);

63
src/engine/Entity.js Normal file
View File

@ -0,0 +1,63 @@
import { getEntityDef } from './constants.js';
/**
* A world entity: enemy or pickup. Holds a stub state machine good enough to
* validate sprites + shooting now. Full enemy AI (patrol/chase/attack) plugs
* into `update()` and the `state` transitions later.
*/
export class Entity {
constructor(spawn) {
this.x = spawn.x;
this.y = spawn.y;
this.type = spawn.type;
this.def =
getEntityDef(spawn.type) ??
{ type: spawn.type, name: spawn.type, color: 0xff00ff, isEnemy: false, health: 0 };
this.health = this.def.health;
this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected'
/** Ms remaining in a transient state (e.g. the hurt flinch flash). */
this.stateTimer = 0;
}
get isEnemy() {
return this.def.isEnemy;
}
/** Still rendered and interactable? Dead enemies and collected items aren't. */
get active() {
return this.state !== 'dead' && this.state !== 'collected';
}
/** Stub billboard color, brightened briefly while flinching from a hit. */
get renderColor() {
if (this.state === 'hurt') return 0xffffff;
return this.def.color;
}
/** Apply damage (enemies only). Returns true if this shot killed it. */
damage(amount) {
if (!this.isEnemy || !this.active) return false;
this.health -= amount;
if (this.health <= 0) {
this.state = 'dead';
return true;
}
this.state = 'hurt';
this.stateTimer = 120;
return false;
}
collect() {
if (!this.isEnemy) this.state = 'collected';
}
update(deltaMs) {
if (this.stateTimer > 0) {
this.stateTimer -= deltaMs;
if (this.stateTimer <= 0 && this.state === 'hurt') {
this.state = 'idle';
}
}
// TODO: enemy AI (patrol/chase/attack/pathfinding) hooks in here.
}
}

47
src/engine/GameMap.js Normal file
View File

@ -0,0 +1,47 @@
import { getTile, isSolid } from './constants.js';
/** Runtime wrapper around a LevelData grid. Shared by the game (collision +
* raycasting) and the editor (paint/erase). Keeps all grid-index math in one
* place so the two never disagree about what is solid or in-bounds. */
export class GameMap {
constructor(level) {
this.width = level.width;
this.height = level.height;
// Defensive copy so mutations (editor painting) don't alias the source.
this.tiles = level.tiles.slice();
}
inBounds(x, y) {
return x >= 0 && y >= 0 && x < this.width && y < this.height;
}
/** Tile id at integer cell (x, y). Out-of-bounds reads as solid wall id 1 so
* rays and movement treat the world edge as a wall. */
tileAt(x, y) {
const cx = Math.floor(x);
const cy = Math.floor(y);
if (!this.inBounds(cx, cy)) return 1;
return this.tiles[cy * this.width + cx];
}
setTile(x, y, id) {
const cx = Math.floor(x);
const cy = Math.floor(y);
if (!this.inBounds(cx, cy)) return;
this.tiles[cy * this.width + cx] = id;
}
/** Whether a wall blocks movement/sight at this cell. */
isWall(x, y) {
return isSolid(this.tileAt(x, y));
}
isDoor(x, y) {
return getTile(this.tileAt(x, y)).isDoor;
}
/** Snapshot the grid back into a plain array (for serialization). */
getTiles() {
return this.tiles.slice();
}
}

122
src/engine/Player.js Normal file
View File

@ -0,0 +1,122 @@
import {
FOV,
MOVE_SPEED,
PLAYER_RADIUS,
WEAPON_RANGE,
WEAPON_DAMAGE,
START_AMMO,
START_HEALTH,
} from './constants.js';
/**
* The player: a camera (position + direction/plane vectors) plus movement with
* grid collision and a simple hitscan weapon. Direction is stored as vectors so
* it feeds the raycaster directly with no per-frame trig in the hot loop.
*/
export class Player {
constructor(start) {
this.health = START_HEALTH;
this.ammo = START_AMMO;
this.score = 0;
const dirX = Math.cos(start.dir);
const dirY = Math.sin(start.dir);
// Camera plane is perpendicular to dir; its length sets the FOV.
const planeLen = Math.tan(FOV / 2);
this.cam = {
posX: start.x,
posY: start.y,
dirX,
dirY,
planeX: -dirY * planeLen,
planeY: dirX * planeLen,
};
}
/** Apply rotation by `angle` radians, rotating both dir and plane vectors. */
rotate(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const { cam } = this;
const dx = cam.dirX;
cam.dirX = dx * c - cam.dirY * s;
cam.dirY = dx * s + cam.dirY * c;
const px = cam.planeX;
cam.planeX = px * c - cam.planeY * s;
cam.planeY = px * s + cam.planeY * c;
}
/** Try to move to (nx, ny), sliding along walls using a small radius so the
* player doesn't clip into or stick on corners. */
tryMove(map, nx, ny) {
const { cam } = this;
const r = PLAYER_RADIUS;
// Resolve each axis independently for wall-sliding.
if (!map.isWall(nx + Math.sign(nx - cam.posX) * r, cam.posY)) {
cam.posX = nx;
}
if (!map.isWall(cam.posX, ny + Math.sign(ny - cam.posY) * r)) {
cam.posY = ny;
}
}
update(map, input, deltaSec) {
if (input.rotate !== 0) this.rotate(input.rotate);
const { cam } = this;
const speed = MOVE_SPEED * deltaSec;
// Forward/back along dir; strafe along the plane (perpendicular).
const moveX = (cam.dirX * input.forward + cam.planeX * input.strafe) * speed;
const moveY = (cam.dirY * input.forward + cam.planeY * input.strafe) * speed;
if (moveX !== 0 || moveY !== 0) {
this.tryMove(map, cam.posX + moveX, cam.posY + moveY);
}
}
/** Fire a hitscan shot down the center of view. Picks the nearest active
* enemy that is both in range and roughly under the crosshair, and that has
* clear line of sight (no wall between). */
shoot(map, entities) {
if (this.ammo <= 0) return { fired: false, hit: null, killed: false };
this.ammo--;
const { cam } = this;
let best = null;
let bestDist = WEAPON_RANGE;
for (const e of entities) {
if (!e.isEnemy || !e.active) continue;
const dx = e.x - cam.posX;
const dy = e.y - cam.posY;
const dist = Math.hypot(dx, dy);
if (dist > WEAPON_RANGE) continue;
// Angle between view direction and the direction to the entity.
const dot = (dx * cam.dirX + dy * cam.dirY) / (dist || 1);
if (dot < 0.985) continue; // ~10° aim cone
if (dist < bestDist && this.hasLineOfSight(map, e)) {
best = e;
bestDist = dist;
}
}
if (!best) return { fired: true, hit: null, killed: false };
const killed = best.damage(WEAPON_DAMAGE);
return { fired: true, hit: best, killed };
}
/** March a coarse ray from the player to the entity; blocked by any wall. */
hasLineOfSight(map, e) {
const { cam } = this;
const dx = e.x - cam.posX;
const dy = e.y - cam.posY;
const dist = Math.hypot(dx, dy);
const steps = Math.ceil(dist * 8);
for (let i = 1; i < steps; i++) {
const t = i / steps;
if (map.isWall(cam.posX + dx * t, cam.posY + dy * t)) return false;
}
return true;
}
}

120
src/engine/Raycaster.js Normal file
View File

@ -0,0 +1,120 @@
import {
RENDER_WIDTH,
RENDER_HEIGHT,
CEILING_COLOR,
FLOOR_COLOR,
getTile,
} from './constants.js';
/** Split an 0xRRGGBB color into [r, g, b]. */
function rgb(color) {
return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff];
}
/**
* DDA raycaster. Renders walls as solid-colored vertical strips into a 2D
* canvas context and records the per-column wall distance in a ZBuffer so the
* sprite renderer can occlude billboards correctly.
*
* This deliberately uses solid colors (no textures) for the first pass; the
* per-column loop is the exact place texture mapping slots in later.
*/
export class Raycaster {
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
this.ctx = ctx;
this.width = width;
this.height = height;
/** Perpendicular wall distance for each screen column (used by sprites). */
this.zBuffer = new Float32Array(width);
}
render(map, cam) {
const { ctx, width, height } = this;
// Flat-shaded ceiling (top half) and floor (bottom half).
const [cr, cg, cb] = rgb(CEILING_COLOR);
ctx.fillStyle = `rgb(${cr},${cg},${cb})`;
ctx.fillRect(0, 0, width, height / 2);
const [fr, fg, fb] = rgb(FLOOR_COLOR);
ctx.fillStyle = `rgb(${fr},${fg},${fb})`;
ctx.fillRect(0, height / 2, width, height / 2);
for (let x = 0; x < width; x++) {
// cameraX ranges -1..1 across the screen; project onto the camera plane.
const cameraX = (2 * x) / width - 1;
const rayDirX = cam.dirX + cam.planeX * cameraX;
const rayDirY = cam.dirY + cam.planeY * cameraX;
let mapX = Math.floor(cam.posX);
let mapY = Math.floor(cam.posY);
// Distance the ray travels to cross one full grid cell in x / y.
const deltaDistX = rayDirX === 0 ? Infinity : Math.abs(1 / rayDirX);
const deltaDistY = rayDirY === 0 ? Infinity : Math.abs(1 / rayDirY);
let stepX;
let stepY;
let sideDistX;
let sideDistY;
if (rayDirX < 0) {
stepX = -1;
sideDistX = (cam.posX - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1 - cam.posX) * deltaDistX;
}
if (rayDirY < 0) {
stepY = -1;
sideDistY = (cam.posY - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1 - cam.posY) * deltaDistY;
}
// March until we hit a solid cell. `side` records which axis we crossed.
let hit = false;
let side = 0; // 0 = x-side (NS wall), 1 = y-side (EW wall)
let tileId = 1;
let guard = 0;
const maxSteps = (map.width + map.height) * 2;
while (!hit && guard++ < maxSteps) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
tileId = map.tileAt(mapX, mapY);
if (getTile(tileId).solid) hit = true;
}
// Perpendicular distance (avoids fisheye) = how far along the ray we are.
const perpDist =
side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
this.zBuffer[x] = perpDist;
const lineHeight = Math.floor(height / perpDist);
let drawStart = Math.floor(-lineHeight / 2 + height / 2);
let drawEnd = Math.floor(lineHeight / 2 + height / 2);
if (drawStart < 0) drawStart = 0;
if (drawEnd >= height) drawEnd = height - 1;
// Shade: y-side walls darker (fake directional light) and dim with
// distance so depth reads clearly even without textures.
let [r, g, b] = rgb(getTile(tileId).color);
const sideMul = side === 1 ? 0.65 : 1.0;
const distMul = Math.max(0.25, 1 - perpDist / 16);
const m = sideMul * distMul;
r = (r * m) | 0;
g = (g * m) | 0;
b = (b * m) | 0;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart + 1);
}
}
}

View File

@ -0,0 +1,75 @@
import { RENDER_WIDTH, RENDER_HEIGHT } from './constants.js';
/** Split an 0xRRGGBB color into [r, g, b]. */
function rgb(color) {
return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff];
}
/**
* Billboard sprite renderer. Projects each entity into camera space, sorts
* back-to-front, and draws it as a screen-space quad clipped per-column against
* the raycaster's ZBuffer so walls correctly occlude sprites.
*
* Stub art: each entity is a flat colored rectangle. Swapping in real sprite
* frames means replacing the inner fillRect with a drawImage of the chosen
* frame, using the same projected rect.
*/
export class SpriteRenderer {
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
this.ctx = ctx;
this.width = width;
this.height = height;
}
render(cam, entities, zBuffer) {
// Sort far-to-near so nearer sprites paint over farther ones.
const visible = entities
.filter((e) => e.active)
.map((e) => ({
e,
dist: (e.x - cam.posX) ** 2 + (e.y - cam.posY) ** 2,
}))
.sort((a, b) => b.dist - a.dist);
// Inverse camera matrix (dir / plane) for world -> camera transform.
const invDet = 1 / (cam.planeX * cam.dirY - cam.dirX * cam.planeY);
for (const { e } of visible) {
const relX = e.x - cam.posX;
const relY = e.y - cam.posY;
// transformY is depth into the screen; transformX is horizontal offset.
const transformX = invDet * (cam.dirY * relX - cam.dirX * relY);
const transformY = invDet * (-cam.planeY * relX + cam.planeX * relY);
if (transformY <= 0.05) continue; // behind the camera / too close
const screenX = Math.floor((this.width / 2) * (1 + transformX / transformY));
// Pickups sit lower and smaller than enemies for a touch of variety.
const scale = e.isEnemy ? 1.0 : 0.5;
const spriteH = Math.abs(Math.floor(this.height / transformY)) * scale;
const spriteW = spriteH; // square billboards
// Anchor pickups to the floor; center enemies vertically.
const vMove = e.isEnemy ? 0 : this.height / transformY / 2;
const centerY = this.height / 2 + vMove;
let drawStartY = Math.floor(centerY - spriteH / 2);
let drawEndY = Math.floor(centerY + spriteH / 2);
if (drawStartY < 0) drawStartY = 0;
if (drawEndY >= this.height) drawEndY = this.height - 1;
const drawStartX = Math.floor(screenX - spriteW / 2);
const drawEndX = Math.floor(screenX + spriteW / 2);
const [r, g, b] = rgb(e.renderColor);
this.ctx.fillStyle = `rgb(${r},${g},${b})`;
// Per-column occlusion: only draw stripes nearer than the wall there.
for (let stripe = drawStartX; stripe <= drawEndX; stripe++) {
if (stripe < 0 || stripe >= this.width) continue;
if (transformY >= zBuffer[stripe]) continue; // wall is in front
this.ctx.fillRect(stripe, drawStartY, 1, drawEndY - drawStartY + 1);
}
}
}
}

115
src/engine/constants.js Normal file
View File

@ -0,0 +1,115 @@
// ---------------------------------------------------------------------------
// Render configuration
// ---------------------------------------------------------------------------
/** Internal render resolution of the raycast view. We render the world at this
* low resolution and upscale the result for the chunky retro look. */
export const RENDER_WIDTH = 320;
export const RENDER_HEIGHT = 200;
/** Field of view. The camera plane length = tan(FOV / 2); 66° ≈ a plane of
* 0.66 against a unit direction vector, matching the classic Wolf3D feel. */
export const FOV = (66 * Math.PI) / 180;
/** Stub floor and ceiling fill colors (0xRRGGBB). */
export const CEILING_COLOR = 0x383838;
export const FLOOR_COLOR = 0x707070;
// ---------------------------------------------------------------------------
// Player tuning
// ---------------------------------------------------------------------------
export const MOVE_SPEED = 3.0; // cells per second
export const ROT_SPEED = 2.4; // radians per second (keyboard turn)
export const MOUSE_SENSITIVITY = 0.0025; // radians per pixel of mouse movement
export const PLAYER_RADIUS = 0.2; // collision radius in cell units
// ---------------------------------------------------------------------------
// Combat tuning
// ---------------------------------------------------------------------------
export const WEAPON_RANGE = 12; // cells
export const WEAPON_DAMAGE = 25;
export const WEAPON_FIRE_COOLDOWN = 250; // ms between shots
export const START_AMMO = 50;
export const START_HEALTH = 100;
// ---------------------------------------------------------------------------
// Tile registry
// ---------------------------------------------------------------------------
// Tile id 0 is always empty floor. Ids 1+ are walls (or, later, doors). The
// `color` is a stub fill until real wall textures are wired in; the id will map
// directly to a texture index at that point.
export const TILES = {
0: { id: 0, name: 'Empty', solid: false, isDoor: false, color: 0x000000 },
1: { id: 1, name: 'Gray Brick', solid: true, isDoor: false, color: 0x9a9a9a },
2: { id: 2, name: 'Blue Stone', solid: true, isDoor: false, color: 0x4a6fa5 },
3: { id: 3, name: 'Red Brick', solid: true, isDoor: false, color: 0xa5503f },
4: { id: 4, name: 'Wood', solid: true, isDoor: false, color: 0x8a6a3a },
5: { id: 5, name: 'Door', solid: true, isDoor: true, color: 0xc8a838 },
};
/** Highest paintable wall id; used by the editor palette. */
export const MAX_TILE_ID = 5;
export function getTile(id) {
return TILES[id] ?? TILES[0];
}
export function isSolid(id) {
return getTile(id).solid;
}
// ---------------------------------------------------------------------------
// Entity registry (stub)
// ---------------------------------------------------------------------------
// Drives both the editor palette and the in-game spawner. `color` is the stub
// billboard color until real sprites are supplied.
export const ENTITIES = {
enemy: { type: 'enemy', name: 'Guard', color: 0xb03030, isEnemy: true, health: 50 },
ammo: { type: 'ammo', name: 'Ammo', color: 0xd0d040, isEnemy: false, health: 0 },
health: { type: 'health', name: 'Health', color: 0x40c040, isEnemy: false, health: 0 },
treasure: { type: 'treasure', name: 'Treasure', color: 0xc0a020, isEnemy: false, health: 0 },
// Walk onto an exit to finish the level and advance to the next one in the
// campaign manifest. A level with no exit instead completes when every guard
// is defeated (see GameScene).
exit: { type: 'exit', name: 'Exit', color: 0x00ff88, isEnemy: false, health: 0 },
};
export function getEntityDef(type) {
return ENTITIES[type];
}
// ---------------------------------------------------------------------------
// Level file format + campaign manifest
// ---------------------------------------------------------------------------
/** Schema version stamped into exported level files. Bump when the on-disk
* shape changes so old files can be detected/upgraded. */
export const LEVEL_FORMAT_VERSION = 1;
/** Default size of a fresh level created in the editor. ~20x the area of the
* old 16x16 grid so you can build big levels; scroll with the arrow keys or a
* middle-drag, and zoom with the mouse wheel. Bump these for even bigger maps —
* the editor only draws the cells currently on screen, so size is cheap. */
export const NEW_LEVEL_WIDTH = 72;
export const NEW_LEVEL_HEIGHT = 72;
/** Folder (relative to the HTML page) that holds level files + the manifest. */
export const LEVELS_DIR = './src/levels/';
/** Manifest listing the campaign's level files, in play order. */
export const MANIFEST_PATH = LEVELS_DIR + 'levels.json';
// ---------------------------------------------------------------------------
// Cross-page handoff
// ---------------------------------------------------------------------------
/** localStorage key the editor writes a level to for "Play Test", and the game
* reads on boot. */
export const PLAYTEST_KEY = 'escape.playtest.level';
/** localStorage key for the editor's persisted working level ("Save"). */
export const EDITOR_SAVE_KEY = 'escape.editor.level';

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

@ -0,0 +1,161 @@
import {
PLAYTEST_KEY,
EDITOR_SAVE_KEY,
LEVEL_FORMAT_VERSION,
LEVELS_DIR,
MANIFEST_PATH,
NEW_LEVEL_WIDTH,
NEW_LEVEL_HEIGHT,
} from './constants.js';
import defaultLevel from '../levels/level1.data.js';
/** The bundled fallback level, deep-cloned so callers can mutate freely. */
export function getDefaultLevel() {
return structuredClone(defaultLevel);
}
/** An empty level with a solid border wall and the player start in the middle —
* the blank canvas the editor opens with and the "New" button creates. */
export function createBlankLevel(
width = NEW_LEVEL_WIDTH,
height = NEW_LEVEL_HEIGHT,
name = 'New Level',
) {
const tiles = new Array(width * height).fill(0);
for (let x = 0; x < width; x++) {
tiles[x] = 1; // top row
tiles[(height - 1) * width + x] = 1; // bottom row
}
for (let y = 0; y < height; y++) {
tiles[y * width] = 1; // left column
tiles[y * width + width - 1] = 1; // right column
}
return {
version: LEVEL_FORMAT_VERSION,
name,
width,
height,
tiles,
playerStart: { x: Math.floor(width / 2) + 0.5, y: Math.floor(height / 2) + 0.5, dir: 0 },
entities: [],
};
}
/** Minimal shape validation so a corrupt localStorage/file blob can't crash
* the renderer. Returns the parsed level or null if it isn't usable. */
export function parseLevel(raw) {
try {
const data = JSON.parse(raw);
if (
typeof data.width === 'number' &&
typeof data.height === 'number' &&
Array.isArray(data.tiles) &&
data.tiles.length === data.width * data.height &&
data.playerStart != null
) {
// Fill in optional fields so downstream code can assume they exist.
data.version ??= LEVEL_FORMAT_VERSION;
data.entities ??= [];
data.name ??= 'Untitled';
return data;
}
} catch {
/* fall through */
}
return null;
}
/** A "Play Test" handoff staged by the editor, or null if none is pending.
* When present it takes precedence over the campaign so the editor can test a
* single unsaved level. */
export function loadStagedPlaytest() {
const pending = localStorage.getItem(PLAYTEST_KEY);
if (!pending) return null;
return parseLevel(pending);
}
/** Fetch the campaign manifest and return its ordered list of level filenames.
* Falls back to just the bundled level's file if the manifest is missing. */
export async function fetchManifest() {
try {
const res = await fetch(MANIFEST_PATH, { cache: 'no-cache' });
if (res.ok) {
const data = await res.json();
if (Array.isArray(data?.levels) && data.levels.length > 0) {
return data.levels.filter((f) => typeof f === 'string');
}
}
} catch {
/* fall through to the built-in fallback */
}
return ['level1.json'];
}
/** Fetch and validate a single level file (a filename from the manifest, or a
* path). Returns the parsed level, or null if it can't be loaded/validated. */
export async function fetchLevel(file) {
const url = /^(\.?\/|https?:)/.test(file) ? file : LEVELS_DIR + file;
try {
const res = await fetch(url, { cache: 'no-cache' });
if (res.ok) return parseLevel(await res.text());
} catch {
/* fall through */
}
return null;
}
/** Load the editor's working level: a previous "Save" if present, else a big
* blank grid to start building on. */
export function loadEditorLevel() {
const saved = localStorage.getItem(EDITOR_SAVE_KEY);
if (saved) {
const parsed = parseLevel(saved);
if (parsed) return parsed;
}
return createBlankLevel();
}
export function saveEditorLevel(level) {
localStorage.setItem(EDITOR_SAVE_KEY, JSON.stringify(level));
}
/** Stage a level for the game to pick up, then open the game page. */
export function playTest(level) {
localStorage.setItem(PLAYTEST_KEY, JSON.stringify(level));
window.open('index.html', '_blank');
}
/** Trigger a browser download of the level as pretty-printed JSON, with fields
* ordered for readability and the current format version stamped in. */
export function exportLevel(level) {
const ordered = {
version: LEVEL_FORMAT_VERSION,
name: level.name,
width: level.width,
height: level.height,
tiles: level.tiles,
playerStart: level.playerStart,
entities: level.entities,
};
const blob = new Blob([JSON.stringify(ordered, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${level.name.replace(/\s+/g, '_').toLowerCase() || 'level'}.json`;
a.click();
URL.revokeObjectURL(url);
}
/** Read a user-selected file and resolve to a LevelData (or reject). */
export function importLevel(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const parsed = parseLevel(String(reader.result));
if (parsed) resolve(parsed);
else reject(new Error('Invalid level file'));
};
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}

26
src/game/main.js Normal file
View File

@ -0,0 +1,26 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene.js';
import { GameScene } from './scenes/GameScene.js';
import { HudScene } from './scenes/HudScene.js';
/** On-screen canvas size. The raycast view is rendered at a low internal
* resolution (RENDER_WIDTH x RENDER_HEIGHT) and upscaled into this space, so
* the HUD stays crisp while the 3D view keeps its chunky retro look. */
export const GAME_WIDTH = 960;
export const GAME_HEIGHT = 600;
const config = {
type: Phaser.AUTO,
parent: 'game',
width: GAME_WIDTH,
height: GAME_HEIGHT,
backgroundColor: '#000000',
pixelArt: true, // nearest-neighbor upscaling of the view texture
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [BootScene, GameScene, HudScene],
};
new Phaser.Game(config);

View File

@ -0,0 +1,50 @@
import Phaser from 'phaser';
import {
loadStagedPlaytest,
fetchManifest,
fetchLevel,
getDefaultLevel,
} from '../../engine/levelIO.js';
/**
* Boot scene: resolves which levels to play, loads the first one, and hands off
* to the game. All level loading is async (fetch), so it happens here — once a
* level is in the registry, GameScene can read it synchronously.
*
* Campaign state lives in the registry under 'campaign':
* { files: string[], index: number, playtest: LevelData | null }
* - `playtest` set → a single unsaved level staged by the editor's Play Test.
* - `files` → the ordered manifest of level files for normal play.
* The current level to build is stored under 'currentLevel'.
*
* This is also where future spritesheets/textures get queued with this.load.*.
*/
export class BootScene extends Phaser.Scene {
constructor() {
super('BootScene');
}
preload() {
// e.g. this.load.spritesheet('guard', 'assets/guard.png', { ... });
}
async create() {
// First boot decides the campaign; later boots (level restarts) reuse it.
if (!this.registry.has('campaign')) {
const playtest = loadStagedPlaytest();
const files = playtest ? [] : await fetchManifest();
this.registry.set('campaign', { files, index: 0, playtest });
}
const campaign = this.registry.get('campaign');
const level = campaign.playtest
? campaign.playtest
: (await fetchLevel(campaign.files[campaign.index])) ?? getDefaultLevel();
this.registry.set('currentLevel', level);
this.scene.start('GameScene');
// HudScene persists across level restarts; only launch it once.
if (!this.scene.isActive('HudScene')) this.scene.launch('HudScene');
}
}

View File

@ -0,0 +1,211 @@
import Phaser from 'phaser';
import { GameMap } from '../../engine/GameMap.js';
import { Player } from '../../engine/Player.js';
import { Entity } from '../../engine/Entity.js';
import { Raycaster } from '../../engine/Raycaster.js';
import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
import {
RENDER_WIDTH,
RENDER_HEIGHT,
ROT_SPEED,
MOUSE_SENSITIVITY,
WEAPON_FIRE_COOLDOWN,
} from '../../engine/constants.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
const VIEW_TEXTURE = 'view';
/**
* The playable scene. Owns the world (map + player + entities), renders the
* raycast view into a CanvasTexture each frame, and drives input. The HUD lives
* in a separate parallel scene that reads `player` from here.
*/
export class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
this.entities = [];
this.yawDelta = 0; // accumulated mouse-look for the current frame
this.lastShot = 0;
}
create() {
// Level was loaded (async) by BootScene and stashed in the registry.
const level = this.registry.get('currentLevel') ?? getDefaultLevel();
this.map = new GameMap(level);
this.player = new Player(level.playerStart);
this.entities = level.entities.map((s) => new Entity(s));
// Carry health/ammo/score forward from the previous level, if any.
const carry = this.registry.get('carryStats');
if (carry) {
this.player.health = carry.health;
this.player.ammo = carry.ammo;
this.player.score = carry.score;
}
// Completion tracking: a level ends when the player reaches an exit, or —
// if it has no exit — when every guard is defeated.
this.hasExit = this.entities.some((e) => e.type === 'exit');
this.hadEnemies = this.entities.some((e) => e.isEnemy);
this.advancing = false;
// Off-screen low-res canvas we paint the world into, then upscale.
if (this.textures.exists(VIEW_TEXTURE)) {
this.textures.remove(VIEW_TEXTURE); // clean slate on scene restart
}
this.viewTexture = this.textures.createCanvas(
VIEW_TEXTURE,
RENDER_WIDTH,
RENDER_HEIGHT,
);
this.ctx = this.viewTexture.context;
this.ctx.imageSmoothingEnabled = false;
this.add
.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, VIEW_TEXTURE)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
this.raycaster = new Raycaster(this.ctx);
this.sprites = new SpriteRenderer(this.ctx);
this.setupInput();
}
setupInput() {
const kb = this.input.keyboard;
this.keys = kb.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE');
// Click to capture the mouse; once captured, clicks fire.
this.input.on('pointerdown', () => {
if (!this.input.mouse?.locked) {
this.input.mouse?.requestPointerLock();
} else {
this.fire();
}
});
// Accumulate horizontal mouse movement while pointer is locked.
this.input.on('pointermove', (pointer) => {
if (this.input.mouse?.locked) {
this.yawDelta += pointer.movementX * MOUSE_SENSITIVITY;
}
});
}
fire() {
if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return;
this.lastShot = this.time.now;
const result = this.player.shoot(this.map, this.entities);
if (!result.fired) {
this.game.events.emit('player-empty');
return;
}
if (result.killed) this.player.score += 100;
this.game.events.emit('player-fired', result);
}
update(_time, deltaMs) {
const dt = deltaMs / 1000;
// --- Build movement intent from keyboard + accumulated mouse look. ---
const k = this.keys;
const forward =
(k.W.isDown || k.UP.isDown ? 1 : 0) - (k.S.isDown || k.DOWN.isDown ? 1 : 0);
const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0);
// Arrow left/right turn when not strafing with mouse.
const keyTurn =
((k.RIGHT.isDown ? 1 : 0) - (k.LEFT.isDown ? 1 : 0)) * ROT_SPEED * dt;
const input = {
forward,
strafe,
rotate: keyTurn + this.yawDelta,
};
this.yawDelta = 0;
this.player.update(this.map, input, dt);
// Keyboard fire fallback (Space), useful before grabbing the mouse.
if (k.SPACE.isDown) this.fire();
for (const e of this.entities) e.update(deltaMs);
this.handlePickups();
// No-exit levels finish when the last guard falls.
if (
!this.hasExit &&
this.hadEnemies &&
!this.entities.some((e) => e.isEnemy && e.active)
) {
this.completeLevel();
}
// --- Render the world. ---
this.raycaster.render(this.map, this.player.cam);
this.sprites.render(this.player.cam, this.entities, this.raycaster.zBuffer);
this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU
}
/** Walk-over pickups: collect nearby active items; reaching an exit ends the
* level. */
handlePickups() {
const { cam } = this.player;
for (const e of this.entities) {
if (e.isEnemy || !e.active) continue;
if (Math.hypot(e.x - cam.posX, e.y - cam.posY) >= 0.4) continue;
if (e.type === 'exit') {
this.completeLevel();
return;
}
e.collect();
this.applyPickup(e);
this.game.events.emit('player-pickup', e.type);
}
}
/** Advance to the next campaign level, or win the game after the last one.
* Guarded so it only fires once even though the trigger is checked per-frame
* while the next level's file is being fetched. */
async completeLevel() {
if (this.advancing) return;
this.advancing = true;
const campaign = this.registry.get('campaign');
const next = campaign.index + 1;
const isLast = campaign.playtest || next >= campaign.files.length;
if (isLast) {
this.game.events.emit('game-won', this.player.score);
this.scene.pause();
return;
}
// Carry health/ammo/score into the next level for a real campaign feel.
this.registry.set('carryStats', {
health: this.player.health,
ammo: this.player.ammo,
score: this.player.score,
});
this.game.events.emit('level-complete');
const level = (await fetchLevel(campaign.files[next])) ?? getDefaultLevel();
campaign.index = next;
this.registry.set('currentLevel', level);
this.scene.restart();
}
applyPickup(e) {
switch (e.type) {
case 'ammo':
this.player.ammo += 10;
break;
case 'health':
this.player.health = Math.min(100, this.player.health + 25);
break;
case 'treasure':
this.player.score += 500;
break;
}
}
}

116
src/game/scenes/HudScene.js Normal file
View File

@ -0,0 +1,116 @@
import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
/**
* Overlay HUD: health / ammo / score readout, a crosshair, a stub weapon at the
* bottom of the screen with a muzzle flash on fire, and a transient message
* line. Runs in parallel with GameScene and reads its `player` each frame.
*/
export class HudScene extends Phaser.Scene {
constructor() {
super('HudScene');
this.messageTimer = 0;
}
create() {
this.gameScene = this.scene.get('GameScene');
const fontStyle = {
fontFamily: 'monospace',
fontSize: '20px',
color: '#ffe066',
};
this.statusText = this.add.text(16, 12, '', fontStyle).setScrollFactor(0);
this.messageText = this.add
.text(GAME_WIDTH / 2, 48, '', { ...fontStyle, color: '#ffffff' })
.setOrigin(0.5, 0)
.setAlpha(0);
// Crosshair (two thin bars).
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
this.add.rectangle(cx, cy, 2, 14, 0xffffff, 0.7);
this.add.rectangle(cx, cy, 14, 2, 0xffffff, 0.7);
// Stub weapon + muzzle flash anchored to the bottom-center.
this.weapon = this.add
.rectangle(cx, GAME_HEIGHT - 50, 70, 110, 0x444444)
.setStrokeStyle(2, 0x222222);
this.muzzle = this.add
.ellipse(cx, GAME_HEIGHT - 110, 40, 30, 0xffd040)
.setAlpha(0);
// Event wiring from the game.
this.gameScene.game.events.on('player-fired', this.onFired, this);
this.gameScene.game.events.on('player-empty', () => this.flash('*click* out of ammo'), this);
this.gameScene.game.events.on('player-pickup', (type) => this.flash(`Picked up ${type}`), this);
this.gameScene.game.events.on('level-complete', () => this.flash('Level complete!'), this);
this.gameScene.game.events.on('game-won', (score) => this.showVictory(score), this);
// Clean up listeners if this scene shuts down.
this.events.once('shutdown', () => {
this.gameScene.game.events.off('player-fired', this.onFired, this);
});
}
/** Full-screen "you escaped" overlay shown after the final level. */
showVictory(score) {
this.add
.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setDepth(10);
this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20, 'YOU ESCAPED!', {
fontFamily: 'monospace',
fontSize: '48px',
color: '#00ff88',
})
.setOrigin(0.5)
.setDepth(11);
this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 34, `Final score: ${score}`, {
fontFamily: 'monospace',
fontSize: '24px',
color: '#ffffff',
})
.setOrigin(0.5)
.setDepth(11);
}
onFired(result) {
// Muzzle flash + a tiny recoil kick on the weapon.
this.muzzle.setAlpha(1);
this.weapon.y += 6;
if (result.killed) this.flash('Guard down! +100');
}
flash(msg) {
this.messageText.setText(msg).setAlpha(1);
this.messageTimer = 1500;
}
update(_time, deltaMs) {
const p = this.gameScene?.player;
if (p) {
this.statusText.setText(
`HEALTH ${p.health} AMMO ${p.ammo} SCORE ${p.score}`,
);
}
// Decay the muzzle flash and recoil.
if (this.muzzle.alpha > 0) {
this.muzzle.setAlpha(Math.max(0, this.muzzle.alpha - deltaMs / 80));
}
const restY = GAME_HEIGHT - 50;
if (this.weapon.y > restY) {
this.weapon.y = Math.max(restY, this.weapon.y - deltaMs / 8);
}
// Fade the transient message.
if (this.messageTimer > 0) {
this.messageTimer -= deltaMs;
if (this.messageTimer <= 0) this.messageText.setAlpha(0);
}
}
}

36
src/levels/level1.data.js Normal file
View File

@ -0,0 +1,36 @@
// Bundled default level, exported as an ES module so it can be imported
// natively with no bundler and no JSON-import-attributes support needed.
// Kept in sync with level1.json; used as an offline fallback if fetch fails.
export default {
"version": 1,
"name": "Level 1 — Sandbox",
"width": 16,
"height": 16,
"tiles": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 4, 4, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 4, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 5, 2, 2, 2, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
],
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
"entities": [
{ "type": "enemy", "x": 6.5, "y": 3.5 },
{ "type": "enemy", "x": 12.5, "y": 12.5 },
{ "type": "ammo", "x": 5.5, "y": 9.5 },
{ "type": "health", "x": 13.5, "y": 5.5 },
{ "type": "treasure", "x": 2.5, "y": 1.5 },
{ "type": "exit", "x": 13.5, "y": 8.5 }
]
};

33
src/levels/level1.json Normal file
View File

@ -0,0 +1,33 @@
{
"version": 1,
"name": "Level 1 — Sandbox",
"width": 16,
"height": 16,
"tiles": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 4, 4, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 4, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 5, 2, 2, 2, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
],
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
"entities": [
{ "type": "enemy", "x": 6.5, "y": 3.5 },
{ "type": "enemy", "x": 12.5, "y": 12.5 },
{ "type": "ammo", "x": 5.5, "y": 9.5 },
{ "type": "health", "x": 13.5, "y": 5.5 },
{ "type": "treasure", "x": 2.5, "y": 1.5 },
{ "type": "exit", "x": 13.5, "y": 8.5 }
]
}

36
src/levels/level2.json Normal file
View File

@ -0,0 +1,36 @@
{
"version": 1,
"name": "Level 2 — The Gauntlet",
"width": 16,
"height": 16,
"tiles": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1,
1, 0, 3, 3, 0, 3, 0, 3, 3, 0, 3, 0, 3, 3, 0, 1,
1, 0, 3, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 3, 0, 1,
1, 0, 3, 0, 2, 2, 2, 0, 3, 3, 3, 3, 0, 3, 0, 1,
1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 1,
1, 0, 3, 0, 2, 0, 4, 4, 4, 0, 0, 3, 3, 3, 0, 1,
1, 0, 3, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 3, 0, 1,
1, 0, 3, 3, 3, 0, 4, 0, 5, 0, 0, 0, 0, 3, 0, 1,
1, 0, 0, 0, 3, 0, 4, 4, 4, 4, 4, 0, 0, 0, 0, 1,
1, 0, 3, 0, 3, 0, 0, 0, 0, 0, 4, 0, 3, 3, 0, 1,
1, 0, 3, 0, 0, 0, 2, 2, 2, 0, 4, 0, 0, 3, 0, 1,
1, 0, 3, 3, 3, 0, 2, 0, 2, 0, 0, 0, 0, 3, 0, 1,
1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 3, 3, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
],
"playerStart": { "x": 1.5, "y": 14.5, "dir": -1.5707963267948966 },
"entities": [
{ "type": "enemy", "x": 4.5, "y": 1.5 },
{ "type": "enemy", "x": 12.5, "y": 1.5 },
{ "type": "enemy", "x": 7.5, "y": 7.5 },
{ "type": "enemy", "x": 12.5, "y": 11.5 },
{ "type": "ammo", "x": 1.5, "y": 1.5 },
{ "type": "ammo", "x": 12.5, "y": 9.5 },
{ "type": "health", "x": 14.5, "y": 14.5 },
{ "type": "treasure", "x": 9.5, "y": 8.5 },
{ "type": "exit", "x": 14.5, "y": 1.5 }
]
}

7
src/levels/levels.json Normal file
View File

@ -0,0 +1,7 @@
{
"_comment": "Campaign manifest: level files (in ./src/levels/) played in order. Add a level by dropping its .json here and listing its filename.",
"levels": [
"level1.json",
"level2.json"
]
}

5
start_web.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
# Serve the game with any static HTTP server — no build, no npm.
# Open http://localhost:3000/ (game) and /editor.html (level editor).
cd "$(dirname "$0")"
python3 -m http.server 3000

1
vendor/phaser.esm.min.js vendored Normal file

File diff suppressed because one or more lines are too long