first commit
This commit is contained in:
commit
8565ed34ec
|
|
@ -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`.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
]
|
||||
};
|
||||
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue