feat: data-driven content with JSON configs, textured walls, and editor overhaul

- Move walls, overlays, items, enemies, and campaigns to JSON registries in
  src/config/ (with offline fallbacks baked into registry.js)
- Upgrade level format to v2: add `overlays` and `items` arrays; migrate
  v1 `"enemy"` types to the default from enemies.json
- Raycaster: full texture mapping via baked canvas textures, overlay compositing,
  and distance/side shading
- SpriteRenderer: texture-based billboards for enemies, pickups, and decorative
  items with per-def scale and flash textures
- Editor: palette with dropdowns populated from config JSONs (wall/overlay/item/
  enemy/start/pickup/erase); paint overlays on walls, items on floors; show
  overlay triangles, item squares, enemy circles in the grid
- GameMap: overlay tracking, blocking-item collision via isBlocked, overlayAt/
  setOverlay helpers
- Player: use isBlocked so blocking items stop movement like walls
- Add MenuScene for campaign selection from campaigns.json; victory screen
  gains "Return to Menu" button
- Bump RENDER_* to 960×600 (1:1 with display canvas)
- Update levels to v2 format with specific enemy types, overlays, and items
This commit is contained in:
Brian Fertig 2026-07-01 22:14:37 -06:00
parent 8565ed34ec
commit 724d4fd1e8
41 changed files with 1788 additions and 280 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.log
.DS_Store

158
README.md
View File

@ -1,9 +1,11 @@
# 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.
authentic raycasting engine: a 2D grid rendered as vertical wall strips, with
textured walls, wall overlays (paintings etc.), decorative items, and multiple
enemy types — **all defined in JSON config files**, so adding content requires
no code changes. Until you drop in real spritesheets, every texture gets a
procedurally generated placeholder so the game always runs.
## Run
@ -36,88 +38,137 @@ Then open:
| 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.
The game opens on a **main menu** listing the campaigns from
`src/config/campaigns.json`. Pick one to play its levels in order; finishing
the last level shows the victory screen with a **Return to Menu** button.
The HUD shows health, ammo, and score. Walk over pickups to collect them; each
enemy type is worth its configured points. Click the view once to capture the
mouse for look controls.
## Content is data-driven — `src/config/`
All content types live in JSON registries. **Adding an entry to a file is all
it takes** — it appears in the editor's dropdowns and renders in game (with
placeholder art until you provide a spritesheet):
| File | Defines | Key fields |
| ---------------- | ---------------------- | ---------- |
| `walls.json` | Wall tiles (ids 1+) | `id` (int, used in level `tiles`), `name`, `solid`, `isDoor`, `color` (`#rrggbb`, placeholder/editor tint), `texture` (sheet path), `frame` |
| `overlays.json` | Wall overlays | `id` (string), `name`, `color`, `texture`, `frame` — transparent PNGs composited over the wall texture |
| `items.json` | Decorative items | `id`, `name`, `color`, `blocking` (true = its cell is impassable), `scale` (height as a fraction of wall height), `texture`, `frame` |
| `enemies.json` | Enemy types | `type`, `name`, `color`, `health`, `speed`, `points`, `texture`, `frame`; plus top-level `defaultType` (what v1 levels' generic "enemy" migrates to) |
| `campaigns.json` | Campaigns for the menu | `id`, `name`, `description`, `levels` (ordered filenames in `src/levels/`) |
Notes:
- Tile id `0` (Empty floor) is built in; don't define it.
- Overlays sit on **wall cells** (one per cell, shown on every face) — think
paintings, banners, vines.
- Items sit on **floor cells**. Blocking items stop the player like a wall but
are not walls: shots and sight pass over them.
- Pickups (`ammo`, `health`, `treasure`) and the `exit` are built-in because
they have hardcoded game effects.
- Each config file has an embedded fallback copy in `src/engine/registry.js`,
so a missing/broken file can't stop the game booting. Keep them in sync if
you change defaults you care about offline.
### Spritesheets — dropping in real art
Every definition points at a `texture` path (relative to the project root) and
a `frame` index. Sheets are **horizontal strips of square frames** (192×192 by
default; `walls.json` has a `textureSize` for other sizes). If the PNG is
missing, the engine bakes a distinctive placeholder pattern in the entry's
`color` instead — so you can build and play everything first and do art last.
**See `sprites.md` for the full list of sheets to draw, with exact dimensions
and frame maps.** Overlay/item/enemy sheets should be transparent PNGs.
## 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).
- **Palette** (top): dropdowns for **Wall / Overlay / Item / Enemy** (populated
from the config JSONs) plus **Start**, pickup, and **Erase** tool buttons.
Focusing a dropdown or clicking a button makes it the active brush.
- **Left-click / drag**: paint the selected brush. Overlays only apply to wall
cells; items only to floor cells. Painting a wall clears anything inside it;
painting floor clears the cell's overlay.
- **Right-click / drag**: erase (clears the tile, overlay, and any entity/item).
- **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.
- **Play Test**: opens the game in a new tab running your current level
(bypassing the menu).
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.
In the grid: overlays show as a corner triangle on their wall cell, items are
squares, enemies are large circles, pickups small circles, and the player start
is the blue arrow.
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.
exit advances to the next level in the campaign. A level with no exit instead
completes when every enemy is defeated.
## Level file format
## Level file format (v2)
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:
the game reads. To author levels, paint one in the editor, Export it, drop the
file in `src/levels/`, and list it in a campaign in `src/config/campaigns.json`.
```jsonc
{
"version": 1, // schema version (stamped on export)
"version": 2, // 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)
// 0 = floor; 1+ = wall ids from walls.json
"overlays": [ // one per wall cell, shown on all faces
{ "x": 3, "y": 0, "id": "painting1" }
],
"items": [ // decorative sprites on floor cells
{ "id": "plant", "x": 4.5, "y": 5.5 }
],
"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 },
"entities": [ // enemies (types from enemies.json) + pickups
{ "type": "guard", "x": 8.5, "y": 5.5 },
{ "type": "heavy", "x": 9.5, "y": 2.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.
Files are validated on load (`parseLevel`) and **v1 files migrate
automatically** (generic `"enemy"` becomes `enemies.json`'s `defaultType`;
missing `overlays`/`items` default to empty). Unknown ids fail safe rather
than crashing.
## Campaign — using your files as game levels
## Campaigns
The game plays the level files listed, in order, in the manifest
`src/levels/levels.json`:
The menu lists the campaigns in `src/config/campaigns.json`; each plays its
`levels` files in order. Reaching an **Exit** advances to the next file; after
the last one the player sees the victory screen. Health, ammo, and score carry
across levels within a campaign. Three campaigns ship as stubs: **Escape**
(the original two levels), **The Depths**, and **Rooftop Run** (one placeholder
level each — `c2_level1.json` / `c3_level1.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`.
`src/levels/levels.json` remains only as a legacy fallback manifest for booting
the game scene directly without the menu.
## Project layout
```
index.html Game page (import map → vendored Phaser)
editor.html Editor page (import map → vendored Phaser)
editor.html Editor page (import map → vendored Phaser; palette DOM)
vendor/ phaser.esm.min.js (vendored so no npm install is needed)
assets/ Your spritesheets (optional — placeholders bake if absent)
src/
engine/ Shared core: constants (TILE & entity registries), GameMap,
Raycaster (DDA + ZBuffer), SpriteRenderer, Player, Entity, levelIO
game/ Phaser bootstrap + Boot/Game/Hud scenes
config/ walls.json, overlays.json, items.json, enemies.json, campaigns.json
engine/ Shared core: constants, registry (JSON configs + texture baking),
GameMap, Raycaster (DDA + textures + overlays + ZBuffer),
SpriteRenderer, Player, Entity, levelIO
game/ Phaser bootstrap + Menu/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)
levels/ level files (+ level1.data.js, the bundled offline fallback kept
in sync with level1.json; levels.json, the legacy manifest)
```
The game and editor are separate HTML entry points that share everything in
@ -134,10 +185,11 @@ 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`;
- **Real art**: drop spritesheets at the paths named in `src/config/*.json`
(see `assets/README.md`) — zero code changes.
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack); each
enemy def already carries a data-driven `speed`.
- **Doors / keys**: door tiles are flagged `isDoor` in `walls.json`;
wire sliding + locked-door logic into `GameMap`/`Raycaster`.
- **Sound, multiple levels, menus**: hook into the scenes in `src/game`.
- **Sound, more campaigns**: add campaign entries to `campaigns.json`; hook
audio into the scenes in `src/game`.

BIN
assets/RAW-Gun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

30
assets/README.md Normal file
View File

@ -0,0 +1,30 @@
# Spritesheets
> Full art brief — every sheet with dimensions, frame maps, and drawing
> notes — lives in [`sprites.md`](../sprites.md) at the project root.
Art is optional: any sheet that is missing (or too small for a requested
frame) gets a procedurally generated placeholder baked in its entry's `color`
from the config JSON, so the game always runs.
## Layout
Every sheet is a **horizontal strip of square frames**, left to right. The
`frame` field in the config JSON is the 0-based index into the strip. Frames
are **192×192** (walls can override via `textureSize` in `walls.json`; baked
output is always 192×192).
Expected files (paths are what `src/config/*.json` references — change them
there if you prefer a different arrangement):
| File | Used by | Notes |
| ----------------------------- | --------------- | ----- |
| `assets/textures/walls.png` | `walls.json` | Opaque. Frame per wall id (frame 0 = Gray Brick, …) |
| `assets/textures/overlays.png`| `overlays.json` | **Transparent PNG** — composited over wall textures |
| `assets/sprites/items.png` | `items.json` | **Transparent PNG** — billboard props (plant, lamp, …) |
| `assets/sprites/enemies.png` | `enemies.json` | **Transparent PNG** — one frame per enemy type for now |
A 5-frame walls sheet is therefore 960×192; a 3-frame enemies sheet 576×192.
You can also point different entries at different files — each unique
`texture` path is loaded once and shared.

BIN
assets/gun1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

BIN
assets/gun2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

BIN
assets/gun3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

BIN
assets/gun4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

BIN
assets/gun5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

BIN
assets/textures/walls.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
assets/textures/walls.psd Normal file

Binary file not shown.

View File

@ -53,6 +53,54 @@
font-size: 12px;
color: #9aa0a6;
}
/* Brush palette: dropdowns populated from the src/config JSON files. */
#palette {
position: absolute;
top: 44px;
left: 0;
right: 0;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 6px 12px;
background: rgba(20, 20, 24, 0.92);
border-bottom: 1px solid #3a3a42;
z-index: 9;
font-size: 12px;
}
#palette label {
display: flex;
gap: 4px;
align-items: center;
color: #9aa0a6;
}
#palette select {
background: #2d2d36;
color: #e6e6e6;
border: 1px solid #45454f;
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
cursor: pointer;
}
#palette button {
background: #2d2d36;
color: #e6e6e6;
border: 1px solid #45454f;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
}
#palette .sep {
width: 1px;
align-self: stretch;
background: #3a3a42;
}
#palette .active-tool {
outline: 2px solid #ffe066;
}
</style>
</head>
<body>
@ -66,6 +114,18 @@
<span class="spacer"></span>
<span id="status"></span>
</div>
<!-- Brush palette. The dropdowns are filled by EditorScene from the
registry (src/config/*.json) — add entries there, not here. -->
<div id="palette">
<label>Wall <select id="sel-wall"></select></label>
<label>Overlay <select id="sel-overlay"></select></label>
<label>Item <select id="sel-item"></select></label>
<label>Enemy <select id="sel-enemy"></select></label>
<span class="sep"></span>
<button id="btn-tool-start">Start</button>
<span id="pickup-tools" style="display: contents"></span>
<button id="btn-tool-erase">Erase</button>
</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

119
sprites.md Normal file
View File

@ -0,0 +1,119 @@
# Spritesheets to make
The game runs fine without any art (it bakes placeholder patterns), but these
are the sheets it will pick up automatically the moment the PNG files exist.
No code changes needed — the paths and frame numbers below come straight from
`src/config/*.json`, so if you rearrange anything, update the JSON to match.
## Format rules (all sheets)
- **PNG**, laid out as a **horizontal strip of square frames**, left to right.
- Frame size is **192×192 px** (set by `textureSize` in `walls.json` for the
wall sheet and by `TEX_SIZE` in `src/engine/registry.js` for the rest;
everything is baked to 192×192 internally, so larger source frames just get
downscaled).
- `frame` in the config JSON is the **0-based index** into the strip.
- The view renders at 960×600 (1:1 with the display canvas), so wall textures
are seen at roughly full detail at one cell's distance. Draw with
**neutral, flat lighting** — the engine adds its own directional shade
(east/west faces darker) and distance dimming.
- Different entries may point at different files; each unique path is loaded
once. A sheet that is missing, or too narrow for a requested frame, falls
back to the placeholder for that entry only.
## 1. `assets/textures/walls.png` — wall textures
**5 frames → 960×192 px. Opaque** (no transparency; these fill the whole wall).
| Frame | Pixels | Wall (`walls.json`) | Placeholder it replaces |
| ----- | --------- | ------------------- | ------------------------------ |
| 0 | 0–191 | id 1 — Gray Brick | gray `#9a9a9a` brick courses |
| 1 | 192–383 | id 2 — Blue Stone | blue `#4a6fa5` pattern |
| 2 | 384–575 | id 3 — Red Brick | red `#a5503f` pattern |
| 3 | 576–767 | id 4 — Wood | brown `#8a6a3a` planks |
| 4 | 768–959 | id 5 — Door | gold `#c8a838` pattern |
Notes:
- One texture covers one full grid cell (all four faces use the same image;
the engine un-mirrors back faces so text/asymmetric details stay readable).
- Make the texture **tileable left-to-right** if you want long runs of the
same wall type to look continuous.
## 2. `assets/textures/overlays.png` — wall overlays
**3 frames → 576×192 px. Transparent background required** — these are
alpha-composited *over* the wall texture, so every transparent pixel shows the
wall behind.
| Frame | Pixels | Overlay (`overlays.json`) | Intended look |
| ----- | ------- | ------------------------- | ------------------------------ |
| 0 | 0–191 | `painting1` — Portrait | framed picture, centered |
| 1 | 192–383 | `banner` — Banner | hanging cloth banner |
| 2 | 384–575 | `vines` — Vines | creeping foliage over the wall |
Notes:
- The 192×192 frame maps to the **entire wall face**; leave generous
transparent margins (the placeholder uses ~30 px) so it reads as an object
*on* the wall.
- One overlay per wall cell, shown on all faces of that block.
## 3. `assets/sprites/items.png` — decorative items
**3 frames → 576×192 px. Transparent background required** — rendered as
floor-anchored billboards that always face the player.
| Frame | Pixels | Item (`items.json`) | In-game height (`scale`) | Blocks? |
| ----- | ------- | ---------------------- | ------------------------ | ------- |
| 0 | 0–191 | `plant` — Potted Plant | 0.6 × wall height | yes |
| 1 | 192–383 | `lamp` — Floor Lamp | 0.8 × wall height | yes |
| 2 | 384–575 | `rubble` — Rubble | 0.4 × wall height | no |
Notes:
- **Fill the frame** with the object (touching the bottom edge) — the engine
handles sizing via `scale` and anchors the frame's bottom to the floor, so
empty space at the bottom of the frame makes the item float.
## 4. `assets/sprites/enemies.png` — enemies
**3 frames → 576×192 px. Transparent background required** — full-wall-height
billboards.
| Frame | Pixels | Enemy (`enemies.json`) | Stats to suggest visually |
| ----- | ------- | ---------------------- | ------------------------------------ |
| 0 | 0–191 | `guard` | baseline: 50 hp, average speed |
| 1 | 192–383 | `heavy` | tanky: 120 hp, slow — bulkier build |
| 2 | 384–575 | `scout` | fragile: 25 hp, fast — lighter build |
Notes:
- One static frame per enemy for now (no walk/attack animation support yet —
when that lands, these will likely grow into multi-frame strips, which is
why keeping each enemy's frames contiguous is a good habit).
- Enemies are drawn at full wall height and stand on the floor; like items,
have the feet touch the bottom edge of the frame.
- The hurt flash is generated automatically (white-tinted copy) — you don't
need to draw one.
## Not needed (yet)
- **Pickups** (ammo, health, treasure) and the **exit** currently always use
their built-in generated diamond markers — there's no `texture` field for
them in a config file yet. If you want art for those too, say so and the
same pattern can be extended to them.
- **Floors/ceilings** are flat colors (`FLOOR_COLOR` / `CEILING_COLOR` in
`src/engine/constants.js`); textured floors are a separate engine feature.
- **Weapon/hands** on the HUD is a gray rectangle stub (`HudScene`).
## Adding more content later
New wall/overlay/item/enemy = one new 192×192 frame appended to the relevant
strip + one JSON entry in `src/config/` pointing at its `frame` index. Nothing
else. You can also start a fresh PNG per entry if you prefer — the `texture`
path is per-entry, not global.
## Changing resolution later
Frame size and render resolution are independent knobs: `TEX_SIZE` in
`src/engine/registry.js` (+ `textureSize` in `walls.json`) for textures,
`RENDER_WIDTH`/`RENDER_HEIGHT` in `src/engine/constants.js` for the view.
Existing art keeps working after a change — frames are scaled at bake time —
but redraw at the new size for full sharpness.

22
src/config/campaigns.json Normal file
View File

@ -0,0 +1,22 @@
{
"campaigns": [
{
"id": "escape",
"name": "Escape",
"description": "Break out of the compound. The original two-level campaign.",
"levels": ["level1.json", "level2.json"]
},
{
"id": "depths",
"name": "The Depths",
"description": "Descend into the tunnels below. (Stub campaign — one placeholder level.)",
"levels": ["c2_level1.json"]
},
{
"id": "rooftop",
"name": "Rooftop Run",
"description": "Race across the skyline to the extraction point. (Stub campaign — one placeholder level.)",
"levels": ["c3_level1.json"]
}
]
}

35
src/config/enemies.json Normal file
View File

@ -0,0 +1,35 @@
{
"defaultType": "guard",
"enemies": [
{
"type": "guard",
"name": "Guard",
"color": "#b03030",
"health": 50,
"speed": 1.5,
"points": 100,
"texture": "assets/sprites/enemies.png",
"frame": 0
},
{
"type": "heavy",
"name": "Heavy",
"color": "#803030",
"health": 120,
"speed": 0.9,
"points": 250,
"texture": "assets/sprites/enemies.png",
"frame": 1
},
{
"type": "scout",
"name": "Scout",
"color": "#d06060",
"health": 25,
"speed": 2.6,
"points": 150,
"texture": "assets/sprites/enemies.png",
"frame": 2
}
]
}

31
src/config/items.json Normal file
View File

@ -0,0 +1,31 @@
{
"items": [
{
"id": "plant",
"name": "Potted Plant",
"color": "#2e8b57",
"blocking": true,
"scale": 0.6,
"texture": "assets/sprites/items.png",
"frame": 0
},
{
"id": "lamp",
"name": "Floor Lamp",
"color": "#e0c060",
"blocking": true,
"scale": 0.8,
"texture": "assets/sprites/items.png",
"frame": 1
},
{
"id": "rubble",
"name": "Rubble",
"color": "#6a6a6a",
"blocking": false,
"scale": 0.4,
"texture": "assets/sprites/items.png",
"frame": 2
}
]
}

25
src/config/overlays.json Normal file
View File

@ -0,0 +1,25 @@
{
"overlays": [
{
"id": "painting1",
"name": "Portrait",
"color": "#c04080",
"texture": "assets/textures/overlays.png",
"frame": 0
},
{
"id": "banner",
"name": "Banner",
"color": "#3050c0",
"texture": "assets/textures/overlays.png",
"frame": 1
},
{
"id": "vines",
"name": "Vines",
"color": "#3f8f3f",
"texture": "assets/textures/overlays.png",
"frame": 2
}
]
}

49
src/config/pickups.json Normal file
View File

@ -0,0 +1,49 @@
{
"pickups": [
{
"id": "ammo",
"name": "Ammo",
"color": "#d0d040",
"effect": "ammo",
"amount": 10,
"texture": "assets/sprites/pickups.png",
"frame": 0
},
{
"id": "health",
"name": "Health",
"color": "#40c040",
"effect": "health",
"amount": 25,
"texture": "assets/sprites/pickups.png",
"frame": 1
},
{
"id": "treasure",
"name": "Treasure",
"color": "#c0a020",
"effect": "score",
"amount": 500,
"texture": "assets/sprites/pickups.png",
"frame": 2
},
{
"id": "key_gold",
"name": "Gold Key",
"color": "#ffd700",
"effect": "key",
"key": "gold",
"texture": "assets/sprites/pickups.png",
"frame": 3
},
{
"id": "key_silver",
"name": "Silver Key",
"color": "#c8c8d8",
"effect": "key",
"key": "silver",
"texture": "assets/sprites/pickups.png",
"frame": 4
}
]
}

50
src/config/walls.json Normal file
View File

@ -0,0 +1,50 @@
{
"textureSize": 192,
"walls": [
{
"id": 1,
"name": "Gray Brick",
"solid": true,
"isDoor": false,
"color": "#9a9a9a",
"texture": "assets/textures/walls.png",
"frame": 0
},
{
"id": 2,
"name": "Blue Stone",
"solid": true,
"isDoor": false,
"color": "#4a6fa5",
"texture": "assets/textures/walls.png",
"frame": 1
},
{
"id": 3,
"name": "Red Brick",
"solid": true,
"isDoor": false,
"color": "#a5503f",
"texture": "assets/textures/walls.png",
"frame": 2
},
{
"id": 4,
"name": "Wood",
"solid": true,
"isDoor": false,
"color": "#8a6a3a",
"texture": "assets/textures/walls.png",
"frame": 3
},
{
"id": 5,
"name": "Door",
"solid": true,
"isDoor": true,
"color": "#c8a838",
"texture": "assets/textures/walls.png",
"frame": 4
}
]
}

View File

@ -1,12 +1,16 @@
import Phaser from 'phaser';
import { GameMap } from '../engine/GameMap.js';
import {
TILES,
ENTITIES,
MAX_TILE_ID,
getTile,
getWall,
getEntityDef,
} from '../engine/constants.js';
getItemDef,
getOverlayDef,
listWalls,
listOverlays,
listItems,
listEnemies,
listPickups,
} from '../engine/registry.js';
import {
loadEditorLevel,
saveEditorLevel,
@ -16,7 +20,7 @@ import {
createBlankLevel,
} from '../engine/levelIO.js';
const TOP_MARGIN = 56; // clears the DOM toolbar
const TOP_MARGIN = 88; // clears the DOM toolbar + palette bar
const MIN_CELL = 12;
const MAX_CELL = 64;
const SCROLL_SPEED = 900; // px/sec for arrow-key scrolling
@ -29,16 +33,24 @@ function cssColor(color) {
/**
* 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.
* (GameMap + the JSON-driven registry) so what you paint is exactly what
* plays. The palette is a row of dropdowns — one per content type (walls,
* overlays, items, enemies), each populated from its src/config JSON file —
* plus Start / pickup / Erase tool buttons. Paint with left-click, erase with
* right-click, pan with middle-drag, zoom with the wheel.
*/
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 };
// { kind: 'wall', id } paint tile id (0 = floor)
// { kind: 'overlay', id } put overlay on an existing wall cell
// { kind: 'item', id } place a decorative item on a floor cell
// { kind: 'enemy', type } place an enemy
// { kind: 'pickup', type } place a pickup / the exit
// { kind: 'start' } move the player start
// { kind: 'erase' } clear everything in the cell
this.brush = { kind: 'wall', id: 1 };
this.cellSize = 32;
this.offsetX = 40;
this.offsetY = TOP_MARGIN + 20;
@ -60,50 +72,85 @@ export class EditorScene extends Phaser.Scene {
// --- 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) => {
const controls = [];
const setActive = (el, brush) => {
this.brush = brush;
for (const b of buttons) b.style.outline = 'none';
btn.style.outline = '2px solid #ffe066';
for (const c of controls) c.classList.remove('active-tool');
el.classList.add('active-tool');
};
const swatch = (label, color, brush) => {
// A dropdown filled from a registry list. Focusing or changing it makes
// its current option the active brush.
const wireSelect = (id, defs, valueOf, labelOf, brushOf) => {
const sel = document.getElementById(id);
for (const def of defs) {
const opt = document.createElement('option');
opt.value = valueOf(def);
opt.textContent = labelOf(def);
sel.appendChild(opt);
}
const activate = () => setActive(sel, brushOf(sel.value));
sel.addEventListener('focus', activate);
sel.addEventListener('change', activate);
controls.push(sel);
return sel;
};
const wallSel = wireSelect(
'sel-wall',
listWalls(),
(d) => d.id,
(d) => (d.id === 0 ? 'Empty (floor)' : `${d.id} — ${d.name}`),
(v) => ({ kind: 'wall', id: Number(v) }),
);
wireSelect(
'sel-overlay',
listOverlays(),
(d) => d.id,
(d) => d.name,
(v) => ({ kind: 'overlay', id: v }),
);
wireSelect(
'sel-item',
listItems(),
(d) => d.id,
(d) => `${d.name}${d.blocking ? ' (blocks)' : ''}`,
(v) => ({ kind: 'item', id: v }),
);
wireSelect(
'sel-enemy',
listEnemies(),
(d) => d.type,
(d) => d.name,
(v) => ({ kind: 'enemy', type: v }),
);
// Tool buttons: player start, one per pickup, eraser.
const wireButton = (btn, brush) => {
btn.addEventListener('click', () => setActive(btn, brush));
controls.push(btn);
};
const startBtn = document.getElementById('btn-tool-start');
startBtn.style.background = '#00d0ff';
startBtn.style.color = '#000';
wireButton(startBtn, { kind: 'start' });
const pickupSpan = document.getElementById('pickup-tools');
for (const def of listPickups()) {
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 });
btn.textContent = def.name;
btn.style.background = cssColor(def.color);
btn.style.color = '#000';
pickupSpan.appendChild(btn);
wireButton(btn, { kind: 'pickup', type: def.type });
}
select(buttons[1], { kind: 'tile', id: 1 }); // default to first wall
wireButton(document.getElementById('btn-tool-erase'), { kind: 'erase' });
// Default brush: the first wall in the dropdown (skip Empty if possible).
const firstWall = listWalls().find((d) => d.id !== 0);
if (firstWall) wallSel.value = String(firstWall.id);
setActive(wallSel, { kind: 'wall', id: firstWall?.id ?? 0 });
}
wireToolbar() {
@ -147,9 +194,12 @@ export class EditorScene extends Phaser.Scene {
});
}
/** Pull edited tiles back into the LevelData before save/export/playtest. */
/** Pull edited tiles/overlays back into the LevelData before
* save/export/playtest. (Items and entities are edited on the level
* directly.) */
syncLevel() {
this.level.tiles = this.map.getTiles();
this.level.overlays = this.map.getOverlays();
}
setStatus(msg) {
@ -196,22 +246,45 @@ export class EditorScene extends Phaser.Scene {
const cell = this.cellUnder(p);
if (!cell) return;
// Right button always erases (tile + any entity in the cell).
// Right button always erases (tile + overlay + any entity/item in the cell).
if (p.rightButtonDown()) {
this.map.setTile(cell.x, cell.y, 0);
this.removeEntityAt(cell.x, cell.y);
this.eraseCell(cell);
return;
}
switch (this.brush.kind) {
case 'tile':
case 'wall':
this.map.setTile(cell.x, cell.y, this.brush.id);
if (getWall(this.brush.id).solid) {
// Nothing can stand inside a wall.
this.removeEntityAt(cell.x, cell.y);
this.removeItemAt(cell.x, cell.y);
} else {
// Overlays live on walls; don't leave one orphaned on a floor tile.
this.map.setOverlay(cell.x, cell.y, null);
}
break;
case 'start':
this.level.playerStart.x = cell.x + 0.5;
this.level.playerStart.y = cell.y + 0.5;
case 'overlay':
if (!this.map.isWall(cell.x, cell.y)) {
this.setStatus('Overlays go on wall cells — paint a wall first.');
return;
}
this.map.setOverlay(cell.x, cell.y, this.brush.id);
break;
case 'entity':
case 'item':
if (this.map.isWall(cell.x, cell.y)) {
this.setStatus('Items go on floor cells.');
return;
}
this.removeItemAt(cell.x, cell.y);
this.level.items.push({
id: this.brush.id,
x: cell.x + 0.5,
y: cell.y + 0.5,
});
break;
case 'enemy':
case 'pickup':
this.removeEntityAt(cell.x, cell.y);
this.level.entities.push({
type: this.brush.type,
@ -219,26 +292,70 @@ export class EditorScene extends Phaser.Scene {
y: cell.y + 0.5,
});
break;
case 'start':
this.level.playerStart.x = cell.x + 0.5;
this.level.playerStart.y = cell.y + 0.5;
break;
case 'erase':
this.eraseCell(cell);
break;
}
}
eraseCell(cell) {
this.map.setTile(cell.x, cell.y, 0);
this.map.setOverlay(cell.x, cell.y, null);
this.removeEntityAt(cell.x, cell.y);
this.removeItemAt(cell.x, cell.y);
}
removeEntityAt(cx, cy) {
this.level.entities = this.level.entities.filter(
(e) => Math.floor(e.x) !== cx || Math.floor(e.y) !== cy,
);
}
removeItemAt(cx, cy) {
this.level.items = this.level.items.filter(
(it) => Math.floor(it.x) !== cx || Math.floor(it.y) !== cy,
);
}
brushLabel() {
const b = this.brush;
switch (b.kind) {
case 'wall':
return `wall:${getWall(b.id).name}`;
case 'overlay':
return `overlay:${getOverlayDef(b.id)?.name ?? b.id}`;
case 'item':
return `item:${getItemDef(b.id)?.name ?? b.id}`;
case 'enemy':
case 'pickup':
return `${b.kind}:${getEntityDef(b.type)?.name ?? b.type}`;
case 'start':
return 'player start';
default:
return 'erase';
}
}
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}`,
if (!cell) {
this.setStatus(`Brush: ${this.brushLabel()}`);
return;
}
// Name what's under the cursor so overlays/items are easy to inspect.
const parts = [`Cell ${cell.x},${cell.y}`];
const overlay = this.map.overlayAt(cell.x, cell.y);
if (overlay) parts.push(`overlay: ${overlay.name}`);
const item = this.level.items.find(
(it) => Math.floor(it.x) === cell.x && Math.floor(it.y) === cell.y,
);
if (item) parts.push(`item: ${getItemDef(item.id)?.name ?? item.id}`);
parts.push(`Brush: ${this.brushLabel()}`);
this.setStatus(parts.join(' | '));
}
// --- Scrolling ------------------------------------------------------------
@ -286,13 +403,21 @@ export class EditorScene extends Phaser.Scene {
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.
// Tiles + overlay markers.
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;
const color = id === 0 ? 0x26262c : getWall(id).color;
g.fillStyle(color, 1);
g.fillRect(ox + x * cs, oy + y * cs, cs - 1, cs - 1);
// Overlay marker: a triangle tucked into the cell's top-right corner.
const overlay = this.map.overlayAt(x, y);
if (overlay) {
const x1 = ox + (x + 1) * cs - 1;
const y0 = oy + y * cs;
g.fillStyle(overlay.color, 1);
g.fillTriangle(x1 - cs * 0.45, y0, x1, y0, x1, y0 + cs * 0.45);
}
}
}
@ -309,12 +434,27 @@ export class EditorScene extends Phaser.Scene {
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);
const onScreen = (e) =>
e.x >= minX - 1 && e.x <= maxX + 1 && e.y >= minY - 1 && e.y <= maxY + 1;
// Items: squares (distinct from the entity circles below).
for (const it of this.level.items) {
if (!onScreen(it)) continue;
const def = getItemDef(it.id);
const s = Math.max(4, cs * 0.45);
g.fillStyle(def?.color ?? 0xff00ff, 1);
g.fillCircle(ox + e.x * cs, oy + e.y * cs, Math.max(3, cs * 0.28));
g.fillRect(ox + it.x * cs - s / 2, oy + it.y * cs - s / 2, s, s);
g.lineStyle(1, 0x000000, 0.8);
g.strokeRect(ox + it.x * cs - s / 2, oy + it.y * cs - s / 2, s, s);
}
// Entities: enemies as large circles, pickups as small ones.
for (const e of this.level.entities) {
if (!onScreen(e)) continue;
const def = getEntityDef(e.type);
const r = def?.isEnemy ? Math.max(3, cs * 0.28) : Math.max(2, cs * 0.18);
g.fillStyle(def?.color ?? 0xff00ff, 1);
g.fillCircle(ox + e.x * cs, oy + e.y * cs, r);
}
// Player start (blue arrow in facing direction).

View File

@ -1,6 +1,12 @@
import Phaser from 'phaser';
import { initRegistry } from '../engine/registry.js';
import { EditorScene } from './EditorScene.js';
// Load the content registries (walls/overlays/items/enemies JSON) before the
// editor builds its palette from them. Top-level await is supported by every
// browser that supports import maps, which this project already requires.
await initRegistry();
// 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 = {
@ -15,4 +21,5 @@ const config = {
scene: [EditorScene],
};
new Phaser.Game(config);
// Exposed for debugging / automated smoke tests.
window.editorGame = new Phaser.Game(config);

View File

@ -1,4 +1,4 @@
import { getEntityDef } from './constants.js';
import { getEntityDef } from './registry.js';
/**
* A world entity: enemy or pickup. Holds a stub state machine good enough to
@ -28,12 +28,6 @@ export class Entity {
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;

View File

@ -1,4 +1,4 @@
import { getTile, isSolid } from './constants.js';
import { getWall, isSolid, getOverlayDef, getItemDef } from './registry.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
@ -9,6 +9,23 @@ export class GameMap {
this.height = level.height;
// Defensive copy so mutations (editor painting) don't alias the source.
this.tiles = level.tiles.slice();
// Sparse per-cell wall overlays (paintings etc.), keyed y*width+x.
this.overlays = new Map();
for (const o of level.overlays ?? []) {
if (this.inBounds(o.x, o.y) && typeof o.id === 'string') {
this.overlays.set(o.y * this.width + o.x, o.id);
}
}
// Cells made impassable by blocking items (plants, lamps, …). Items are
// not walls: rays, shots, and sight pass over them; only movement stops.
this.blocked = new Set();
for (const it of level.items ?? []) {
if (getItemDef(it.id)?.blocking && this.inBounds(it.x, it.y)) {
this.blocked.add(Math.floor(it.y) * this.width + Math.floor(it.x));
}
}
}
inBounds(x, y) {
@ -31,17 +48,50 @@ export class GameMap {
this.tiles[cy * this.width + cx] = id;
}
/** Whether a wall blocks movement/sight at this cell. */
/** Whether a wall blocks sight/shots/rays at this cell. */
isWall(x, y) {
return isSolid(this.tileAt(x, y));
}
/** Whether movement is blocked here — by a wall or by a blocking item. */
isBlocked(x, y) {
if (this.isWall(x, y)) return true;
const cx = Math.floor(x);
const cy = Math.floor(y);
return this.inBounds(cx, cy) && this.blocked.has(cy * this.width + cx);
}
isDoor(x, y) {
return getTile(this.tileAt(x, y)).isDoor;
return getWall(this.tileAt(x, y)).isDoor;
}
/** Overlay def on the wall cell at (x, y), or null. */
overlayAt(x, y) {
const id = this.overlays.get(Math.floor(y) * this.width + Math.floor(x));
return id ? getOverlayDef(id) : null;
}
/** Set (id string) or clear (null) the overlay on a cell — editor brush. */
setOverlay(x, y, id) {
const cx = Math.floor(x);
const cy = Math.floor(y);
if (!this.inBounds(cx, cy)) return;
const key = cy * this.width + cx;
if (id) this.overlays.set(key, id);
else this.overlays.delete(key);
}
/** Snapshot the grid back into a plain array (for serialization). */
getTiles() {
return this.tiles.slice();
}
/** Snapshot overlays as the level file's [{x, y, id}] array. */
getOverlays() {
return [...this.overlays.entries()].map(([key, id]) => ({
x: key % this.width,
y: Math.floor(key / this.width),
id,
}));
}
}

View File

@ -47,15 +47,16 @@ export class Player {
}
/** Try to move to (nx, ny), sliding along walls using a small radius so the
* player doesn't clip into or stick on corners. */
* player doesn't clip into or stick on corners. Uses isBlocked so blocking
* items (plants, lamps, …) stop movement like walls do. */
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)) {
if (!map.isBlocked(nx + Math.sign(nx - cam.posX) * r, cam.posY)) {
cam.posX = nx;
}
if (!map.isWall(cam.posX, ny + Math.sign(ny - cam.posY) * r)) {
if (!map.isBlocked(cam.posX, ny + Math.sign(ny - cam.posY) * r)) {
cam.posY = ny;
}
}

View File

@ -3,21 +3,26 @@ import {
RENDER_HEIGHT,
CEILING_COLOR,
FLOOR_COLOR,
getTile,
} from './constants.js';
import { getWall, TEX_SIZE } from './registry.js';
/** Split an 0xRRGGBB color into [r, g, b]. */
function rgb(color) {
return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff];
}
/** Cap on wall strip height so a near-zero perpDist can't produce an
* Infinity-sized drawImage right as the camera brushes a wall. */
const MAX_LINE_FACTOR = 32;
/**
* 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.
* DDA raycaster. For each screen column it marches the grid to the nearest
* solid wall, then draws a 1px-wide vertical strip of that wall's texture
* (a baked canvas from the registry — real spritesheet frame or placeholder),
* composites the cell's overlay texture (painting/banner/…) over it, and
* finally applies shading as a translucent black rect. The per-column wall
* distance is recorded in a ZBuffer so the sprite renderer can occlude
* billboards correctly.
*/
export class Raycaster {
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
@ -39,6 +44,10 @@ export class Raycaster {
ctx.fillStyle = `rgb(${fr},${fg},${fb})`;
ctx.fillRect(0, height / 2, width, height / 2);
// Wall defs change rarely between adjacent columns; memoize the lookup.
let lastId = -1;
let lastDef = null;
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;
@ -89,32 +98,66 @@ export class Raycaster {
side = 1;
}
tileId = map.tileAt(mapX, mapY);
if (getTile(tileId).solid) hit = true;
if (tileId !== lastId) {
lastDef = getWall(tileId);
lastId = tileId;
}
if (lastDef.solid) hit = true;
}
const def = lastDef;
// 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;
const lineHeight = Math.min(
Math.floor(height / perpDist),
height * MAX_LINE_FACTOR,
);
const top = Math.floor((height - lineHeight) / 2);
let drawStart = Math.max(0, top);
let drawEnd = Math.min(height - 1, top + lineHeight - 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);
// Texture x-coordinate: where along the wall face the ray hit (0..1).
let wallX =
side === 0
? cam.posY + perpDist * rayDirY
: cam.posX + perpDist * rayDirX;
wallX -= Math.floor(wallX);
let texX = Math.floor(wallX * TEX_SIZE);
// Un-mirror the two faces that would otherwise render flipped.
if ((side === 0 && rayDirX > 0) || (side === 1 && rayDirY < 0)) {
texX = TEX_SIZE - 1 - texX;
}
// Wall strip: 1px-wide source column scaled to the strip height. The
// canvas clips overdraw when the wall is taller than the screen.
if (def.tex) {
ctx.drawImage(def.tex, texX, 0, 1, TEX_SIZE, x, top, 1, lineHeight);
} else {
// Registry not baked yet (first frames / tests): flat color fallback.
const [r, g, b] = rgb(def.color);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart + 1);
}
// Overlay (painting, banner, …) alpha-composited over the wall texture,
// sharing the same texture coordinate so it sticks to the wall.
const overlay = map.overlayAt(mapX, mapY);
if (overlay?.tex) {
ctx.drawImage(overlay.tex, texX, 0, 1, TEX_SIZE, x, top, 1, lineHeight);
}
// Shade last so it darkens wall + overlay together: y-side walls darker
// (fake directional light) and dim with distance so depth reads clearly.
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);
if (m < 0.995) {
ctx.fillStyle = `rgba(0,0,0,${(1 - m).toFixed(3)})`;
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart + 1);
}
}
}
}

View File

@ -1,18 +1,14 @@
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];
}
import { TEX_SIZE } from './registry.js';
/**
* 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.
* Billboard sprite renderer. Projects each renderable (enemy, pickup, or
* decorative item) into camera space, sorts back-to-front, and draws its
* texture as 1px-wide vertical strips 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.
* A renderable needs: x, y, active, isEnemy, and a def with tex (+ optional
* texFlash and scale). Entities and GameScene's item props both fit.
*/
export class SpriteRenderer {
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
@ -21,9 +17,9 @@ export class SpriteRenderer {
this.height = height;
}
render(cam, entities, zBuffer) {
render(cam, renderables, zBuffer) {
// Sort far-to-near so nearer sprites paint over farther ones.
const visible = entities
const visible = renderables
.filter((e) => e.active)
.map((e) => ({
e,
@ -45,30 +41,38 @@ export class SpriteRenderer {
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;
// Enemies are full wall height; items/pickups use their def's scale.
const scale = e.isEnemy ? 1.0 : (e.def?.scale ?? 0.5);
const fullH = this.height / transformY;
const spriteH = Math.abs(Math.floor(fullH)) * scale;
const spriteW = spriteH; // square billboards
if (spriteH < 1) continue; // too far away to draw
// Anchor pickups to the floor; center enemies vertically.
const vMove = e.isEnemy ? 0 : this.height / transformY / 2;
// Anchor items/pickups to the floor; center enemies vertically.
const vMove = e.isEnemy ? 0 : fullH / 2 - spriteH / 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;
// Unclamped bounds — the canvas clips vertically, keeping the 1:1
// mapping between source texture rows and on-screen pixels.
const drawStartY = Math.floor(centerY - spriteH / 2);
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})`;
const tex = e.state === 'hurt' ? e.def.texFlash : e.def?.tex;
if (!tex) continue; // registry not baked yet (first frames / tests)
// 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);
const texX = Math.min(
TEX_SIZE - 1,
Math.floor(((stripe - drawStartX) / spriteW) * TEX_SIZE),
);
this.ctx.drawImage(
tex,
texX, 0, 1, TEX_SIZE,
stripe, drawStartY, 1, spriteH,
);
}
}
}

View File

@ -2,10 +2,12 @@
// 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;
/** Internal render resolution of the raycast view. Originally 320×200 for the
* chunky retro look; now tripled — 1:1 with the 960×600 display canvas (still
* upscaled cleanly on larger windows). Drop these back down for more chunk,
* or if a low-end machine struggles. */
export const RENDER_WIDTH = 960;
export const RENDER_HEIGHT = 600;
/** 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. */
@ -35,60 +37,32 @@ export const START_AMMO = 50;
export const START_HEALTH = 100;
// ---------------------------------------------------------------------------
// Tile registry
// Door tuning
// ---------------------------------------------------------------------------
// 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;
}
export const DOOR_SLIDE_MS = 900; // time for a door to slide fully open/closed
export const DOOR_OPEN_MS = 4000; // how long a door stays open before closing
// ---------------------------------------------------------------------------
// Entity registry (stub)
// Content registries
// ---------------------------------------------------------------------------
// Drives both the editor palette and the in-game spawner. `color` is the stub
// billboard color until real sprites are supplied.
// Wall tiles, overlays, items, enemies, and campaigns are all data-driven:
// defined in the JSON files under CONFIG_DIR and loaded by engine/registry.js
// at boot. See that module for the accessors (getWall, getEntityDef, …).
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];
}
/** Folder (relative to the HTML page) holding the content config JSON files
* (walls.json, overlays.json, items.json, enemies.json, campaigns.json). */
export const CONFIG_DIR = './src/config/';
// ---------------------------------------------------------------------------
// 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;
* shape changes so old files can be detected/upgraded.
* v2: entities carry enemy types (e.g. "guard"); adds `overlays` and `items`
* arrays. v1 files are migrated on load (see levelIO.migrateLevel). */
export const LEVEL_FORMAT_VERSION = 2;
/** 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

View File

@ -7,11 +7,35 @@ import {
NEW_LEVEL_WIDTH,
NEW_LEVEL_HEIGHT,
} from './constants.js';
import { getDefaultEnemyType } from './registry.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);
return migrateLevel(structuredClone(defaultLevel));
}
/** Upgrade a level object to the current format version, in place.
* v1 → v2: the generic "enemy" entity type becomes the default enemy type
* from enemies.json, and the `overlays` / `items` arrays are added. */
export function migrateLevel(data) {
if ((data.version ?? 1) < 2) {
for (const e of data.entities ?? []) {
if (e.type === 'enemy') e.type = getDefaultEnemyType();
}
}
data.version = LEVEL_FORMAT_VERSION;
data.entities ??= [];
data.name ??= 'Untitled';
// Keep only well-formed overlay/item entries so a hand-edited file can't
// crash the renderer; ids are validated against the registry at use sites.
data.overlays = (data.overlays ?? []).filter(
(o) => Number.isInteger(o?.x) && Number.isInteger(o?.y) && typeof o?.id === 'string',
);
data.items = (data.items ?? []).filter(
(it) => typeof it?.x === 'number' && typeof it?.y === 'number' && typeof it?.id === 'string',
);
return data;
}
/** An empty level with a solid border wall and the player start in the middle —
@ -36,6 +60,8 @@ export function createBlankLevel(
width,
height,
tiles,
overlays: [],
items: [],
playerStart: { x: Math.floor(width / 2) + 0.5, y: Math.floor(height / 2) + 0.5, dir: 0 },
entities: [],
};
@ -53,11 +79,9 @@ export function parseLevel(raw) {
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;
// Upgrade old versions + fill in optional fields so downstream code
// can assume they exist.
return migrateLevel(data);
}
} catch {
/* fall through */
@ -65,12 +89,15 @@ export function parseLevel(raw) {
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() {
/** Consume the "Play Test" handoff staged by the editor: return the level (or
* null if none is pending) and clear it. When present it takes precedence
* over the campaign and bypasses the menu, so the editor can test a single
* unsaved level — but only for that one boot. Consuming the key here is what
* keeps a finished playtest from hijacking every later visit to the game. */
export function takeStagedPlaytest() {
const pending = localStorage.getItem(PLAYTEST_KEY);
if (!pending) return null;
localStorage.removeItem(PLAYTEST_KEY);
return parseLevel(pending);
}
@ -134,6 +161,8 @@ export function exportLevel(level) {
width: level.width,
height: level.height,
tiles: level.tiles,
overlays: level.overlays ?? [],
items: level.items ?? [],
playerStart: level.playerStart,
entities: level.entities,
};

523
src/engine/registry.js Normal file
View File

@ -0,0 +1,523 @@
import { CONFIG_DIR } from './constants.js';
/**
* Data-driven content registry. All game content types — wall tiles, wall
* overlays (paintings etc.), decorative items, enemy types, and campaigns —
* are defined in JSON files under src/config/ and loaded here once at boot
* (game and editor both call initRegistry()).
*
* After init, everything reads the registry synchronously via the accessors
* below, mirroring how the old hardcoded TILES/ENTITIES tables were used.
*
* Textures: each definition may name a spritesheet `texture` (path relative to
* the HTML page) and a `frame` index into a horizontal strip of square frames.
* At init every definition gets a baked TEX_SIZE×TEX_SIZE canvas in `def.tex`
* (enemies also `def.texFlash` for the hurt flash) — cut from the sheet when
* the image exists, otherwise a procedurally generated placeholder in the
* definition's color. Renderers can therefore drawImage(def.tex, …)
* unconditionally; dropping real PNGs into assets/ requires no code changes.
*/
/** Square size (px) of all baked texture canvases (and the default source
* frame size for spritesheets — see sprites.md). */
export const TEX_SIZE = 192;
// ---------------------------------------------------------------------------
// Embedded fallback config
// ---------------------------------------------------------------------------
// Mirrors of the src/config/*.json files (the same way level1.data.js mirrors
// level1.json) so a failed fetch — e.g. file:// quirks or a missing file —
// never breaks boot. Keep these in sync when editing the JSON files.
const DEFAULT_WALLS = {
textureSize: 192,
walls: [
{ id: 1, name: 'Gray Brick', solid: true, isDoor: false, color: '#9a9a9a', texture: 'assets/textures/walls.png', frame: 0 },
{ id: 2, name: 'Blue Stone', solid: true, isDoor: false, color: '#4a6fa5', texture: 'assets/textures/walls.png', frame: 1 },
{ id: 3, name: 'Red Brick', solid: true, isDoor: false, color: '#a5503f', texture: 'assets/textures/walls.png', frame: 2 },
{ id: 4, name: 'Wood', solid: true, isDoor: false, color: '#8a6a3a', texture: 'assets/textures/walls.png', frame: 3 },
{ id: 5, name: 'Door', solid: true, isDoor: true, color: '#c8a838', texture: 'assets/textures/walls.png', frame: 4 },
],
};
const DEFAULT_OVERLAYS = {
overlays: [
{ id: 'painting1', name: 'Portrait', color: '#c04080', texture: 'assets/textures/overlays.png', frame: 0 },
{ id: 'banner', name: 'Banner', color: '#3050c0', texture: 'assets/textures/overlays.png', frame: 1 },
{ id: 'vines', name: 'Vines', color: '#3f8f3f', texture: 'assets/textures/overlays.png', frame: 2 },
],
};
const DEFAULT_ITEMS = {
items: [
{ id: 'plant', name: 'Potted Plant', color: '#2e8b57', blocking: true, scale: 0.6, texture: 'assets/sprites/items.png', frame: 0 },
{ id: 'lamp', name: 'Floor Lamp', color: '#e0c060', blocking: true, scale: 0.8, texture: 'assets/sprites/items.png', frame: 1 },
{ id: 'rubble', name: 'Rubble', color: '#6a6a6a', blocking: false, scale: 0.4, texture: 'assets/sprites/items.png', frame: 2 },
],
};
const DEFAULT_ENEMIES = {
defaultType: 'guard',
enemies: [
{ type: 'guard', name: 'Guard', color: '#b03030', health: 50, speed: 1.5, points: 100, texture: 'assets/sprites/enemies.png', frame: 0 },
{ type: 'heavy', name: 'Heavy', color: '#803030', health: 120, speed: 0.9, points: 250, texture: 'assets/sprites/enemies.png', frame: 1 },
{ type: 'scout', name: 'Scout', color: '#d06060', health: 25, speed: 2.6, points: 150, texture: 'assets/sprites/enemies.png', frame: 2 },
],
};
const DEFAULT_CAMPAIGNS = {
campaigns: [
{ id: 'escape', name: 'Escape', description: 'Break out of the compound.', levels: ['level1.json', 'level2.json'] },
{ id: 'depths', name: 'The Depths', description: 'Descend into the tunnels below.', levels: ['c2_level1.json'] },
{ id: 'rooftop', name: 'Rooftop Run', description: 'Race across the skyline.', levels: ['c3_level1.json'] },
],
};
/** Pickups (and the exit) have hardcoded game-logic effects (GameScene), so
* they stay a built-in table rather than a config file. They resolve through
* the same getEntityDef() as enemies. */
const PICKUPS = [
{ type: 'ammo', name: 'Ammo', color: '#d0d040' },
{ type: 'health', name: 'Health', color: '#40c040' },
{ type: 'treasure', name: 'Treasure', color: '#c0a020' },
{ type: 'exit', name: 'Exit', color: '#00ff88' },
];
/** The built-in floor tile; wall id 0 is always Empty and not configurable. */
const EMPTY_TILE = { id: 0, name: 'Empty', solid: false, isDoor: false, color: 0x000000 };
// ---------------------------------------------------------------------------
// Registry state
// ---------------------------------------------------------------------------
const reg = {
walls: new Map(), // id (int) -> wall def
wallTextureSize: TEX_SIZE, // source frame size in walls.png
overlays: new Map(), // id (string) -> overlay def
items: new Map(), // id (string) -> item def
enemies: new Map(), // type (string) -> enemy def
pickups: new Map(), // type (string) -> pickup def
defaultEnemyType: 'guard',
campaigns: [],
};
/** Parse "#rrggbb" (or a legacy int) to an 0xRRGGBB int. */
function parseColor(c, fallback = 0xff00ff) {
if (typeof c === 'number') return c;
if (typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c)) {
return parseInt(c.slice(1), 16);
}
return fallback;
}
function applyWalls(data) {
reg.walls.clear();
reg.walls.set(0, { ...EMPTY_TILE });
reg.wallTextureSize = typeof data.textureSize === 'number' ? data.textureSize : TEX_SIZE;
for (const w of data.walls ?? []) {
if (!Number.isInteger(w.id) || w.id <= 0) continue;
reg.walls.set(w.id, {
id: w.id,
name: w.name ?? `Wall ${w.id}`,
solid: w.solid !== false,
isDoor: w.isDoor === true,
color: parseColor(w.color, 0x9a9a9a),
texture: w.texture ?? null,
frame: w.frame ?? 0,
});
}
}
function applyOverlays(data) {
reg.overlays.clear();
for (const o of data.overlays ?? []) {
if (typeof o.id !== 'string' || !o.id) continue;
reg.overlays.set(o.id, {
id: o.id,
name: o.name ?? o.id,
color: parseColor(o.color),
texture: o.texture ?? null,
frame: o.frame ?? 0,
});
}
}
function applyItems(data) {
reg.items.clear();
for (const it of data.items ?? []) {
if (typeof it.id !== 'string' || !it.id) continue;
reg.items.set(it.id, {
id: it.id,
name: it.name ?? it.id,
color: parseColor(it.color),
blocking: it.blocking === true,
scale: typeof it.scale === 'number' ? it.scale : 0.5,
texture: it.texture ?? null,
frame: it.frame ?? 0,
isEnemy: false,
});
}
}
function applyEnemies(data) {
reg.enemies.clear();
for (const e of data.enemies ?? []) {
if (typeof e.type !== 'string' || !e.type) continue;
reg.enemies.set(e.type, {
type: e.type,
name: e.name ?? e.type,
color: parseColor(e.color, 0xb03030),
health: typeof e.health === 'number' ? e.health : 50,
speed: typeof e.speed === 'number' ? e.speed : 1.5,
points: typeof e.points === 'number' ? e.points : 100,
texture: e.texture ?? null,
frame: e.frame ?? 0,
isEnemy: true,
});
}
if (typeof data.defaultType === 'string' && reg.enemies.has(data.defaultType)) {
reg.defaultEnemyType = data.defaultType;
} else {
reg.defaultEnemyType = reg.enemies.keys().next().value ?? 'guard';
}
}
function applyCampaigns(data) {
reg.campaigns = (data.campaigns ?? [])
.filter((c) => typeof c.id === 'string' && Array.isArray(c.levels) && c.levels.length > 0)
.map((c) => ({
id: c.id,
name: c.name ?? c.id,
description: c.description ?? '',
levels: c.levels.filter((f) => typeof f === 'string'),
}));
}
function applyPickups() {
reg.pickups.clear();
for (const p of PICKUPS) {
reg.pickups.set(p.type, {
type: p.type,
name: p.name,
color: parseColor(p.color),
isEnemy: false,
health: 0,
});
}
}
// Populate from the embedded defaults immediately so accessors are safe even
// before (or without) initRegistry() — e.g. level migration during import.
applyWalls(DEFAULT_WALLS);
applyOverlays(DEFAULT_OVERLAYS);
applyItems(DEFAULT_ITEMS);
applyEnemies(DEFAULT_ENEMIES);
applyCampaigns(DEFAULT_CAMPAIGNS);
applyPickups();
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
async function fetchConfig(file, fallback) {
try {
const res = await fetch(CONFIG_DIR + file, { cache: 'no-cache' });
if (res.ok) return await res.json();
} catch {
/* fall through */
}
console.warn(`[registry] Could not load ${file}; using built-in defaults.`);
return fallback;
}
/** Load an image, resolving null (not rejecting) if it's missing/broken. */
function loadImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
let initPromise = null;
/** Fetch all config JSONs, load their spritesheets, and bake per-definition
* texture canvases. Idempotent — the game and editor can both call it. */
export function initRegistry() {
initPromise ??= doInit();
return initPromise;
}
async function doInit() {
const [walls, overlays, items, enemies, campaigns] = await Promise.all([
fetchConfig('walls.json', DEFAULT_WALLS),
fetchConfig('overlays.json', DEFAULT_OVERLAYS),
fetchConfig('items.json', DEFAULT_ITEMS),
fetchConfig('enemies.json', DEFAULT_ENEMIES),
fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS),
]);
applyWalls(walls);
applyOverlays(overlays);
applyItems(items);
applyEnemies(enemies);
applyCampaigns(campaigns);
applyPickups();
// Load every referenced spritesheet once.
const defs = [
...reg.walls.values(),
...reg.overlays.values(),
...reg.items.values(),
...reg.enemies.values(),
];
const paths = [...new Set(defs.map((d) => d.texture).filter(Boolean))];
const images = new Map(
await Promise.all(paths.map(async (p) => [p, await loadImage(p)])),
);
for (const [p, img] of images) {
if (!img) console.info(`[registry] No spritesheet at "${p}" — using placeholder art.`);
}
// Bake a canvas per definition: sheet frame if available, else placeholder.
for (const def of reg.walls.values()) {
if (def.id === 0) continue;
def.tex = bakeFrame(images.get(def.texture), def.frame, reg.wallTextureSize)
?? makeWallPlaceholder(def);
}
for (const def of reg.overlays.values()) {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeOverlayPlaceholder(def);
}
for (const def of reg.items.values()) {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeSpritePlaceholder(def, 'item');
}
for (const def of reg.enemies.values()) {
def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeSpritePlaceholder(def, 'enemy');
def.texFlash = makeFlashVariant(def.tex);
}
for (const def of reg.pickups.values()) {
def.tex = makeSpritePlaceholder(def, 'pickup');
}
}
// ---------------------------------------------------------------------------
// Texture baking
// ---------------------------------------------------------------------------
function makeCanvas() {
const c = document.createElement('canvas');
c.width = TEX_SIZE;
c.height = TEX_SIZE;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = false;
return [c, ctx];
}
/** Cut frame `n` (a horizontal strip of frameSize² frames) out of a sheet into
* a TEX_SIZE canvas. Returns null if the sheet didn't load or is too small. */
function bakeFrame(sheet, n, frameSize) {
if (!sheet) return null;
if (sheet.width < (n + 1) * frameSize) return null;
const [c, ctx] = makeCanvas();
ctx.drawImage(sheet, n * frameSize, 0, frameSize, frameSize, 0, 0, TEX_SIZE, TEX_SIZE);
return c;
}
/** CSS color for `color` scaled by `mul` (0 = black, 1 = unchanged, >1 lighter). */
function shade(color, mul) {
const ch = (v) => Math.max(0, Math.min(255, Math.round(v * mul)));
return `rgb(${ch((color >> 16) & 0xff)},${ch((color >> 8) & 0xff)},${ch(color & 0xff)})`;
}
/** Cheap deterministic hash so each id gets a stable placeholder pattern. */
function hashId(id) {
let h = 0;
for (const ch of String(id)) h = (h * 31 + ch.codePointAt(0)) | 0;
return Math.abs(h);
}
/** Opaque placeholder wall texture: base color + a pattern picked by id.
* All measurements are fractions of TEX_SIZE so the look survives resizes. */
function makeWallPlaceholder(def) {
const [c, ctx] = makeCanvas();
const S = TEX_SIZE;
const B = S / 4; // base pattern cell
const t = Math.max(1, S / 32); // line thickness
ctx.fillStyle = shade(def.color, 1);
ctx.fillRect(0, 0, S, S);
ctx.fillStyle = shade(def.color, 0.7);
switch (hashId(def.id) % 4) {
case 0: // brick courses
for (let y = 0; y < S; y += B) {
ctx.fillRect(0, y, S, t);
const off = (y / B) % 2 ? B : 0;
for (let x = off; x < S; x += 2 * B) ctx.fillRect(x, y, t, B);
}
break;
case 1: // checker
for (let y = 0; y < S; y += B) {
for (let x = 0; x < S; x += B) {
if (((x + y) / B) % 2 === 0) ctx.fillRect(x, y, B, B);
}
}
break;
case 2: // diagonal stripes
for (let y = 0; y < S; y++) {
for (let x = y % B; x < S; x += B) ctx.fillRect(x, y, S / 10, 1);
}
break;
default: // vertical planks
for (let x = 0; x < S; x += B) ctx.fillRect(x, 0, S / 20, S);
break;
}
// Highlight + border so wall boundaries read clearly.
ctx.fillStyle = shade(def.color, 1.25);
ctx.fillRect(0, 0, S, t);
ctx.strokeStyle = shade(def.color, 0.45);
ctx.lineWidth = t;
ctx.strokeRect(t / 2, t / 2, S - t, S - t);
return c;
}
/** Transparent-background placeholder overlay: a framed "picture" occupying
* the middle of the tile so alpha compositing over the wall is visible. */
function makeOverlayPlaceholder(def) {
const [c, ctx] = makeCanvas();
const S = TEX_SIZE;
const m = S * 0.16; // transparent margin
const b = S / 16; // frame border width
ctx.fillStyle = shade(def.color, 0.5);
ctx.fillRect(m, m, S - 2 * m, S - 2 * m); // frame
ctx.fillStyle = shade(def.color, 1.1);
ctx.fillRect(m + b, m + b, S - 2 * (m + b), S - 2 * (m + b)); // canvas
ctx.fillStyle = shade(def.color, 0.75);
ctx.beginPath(); // simple motif
ctx.arc(S / 2, S / 2, S / 2 - m - S * 0.16, 0, Math.PI * 2);
ctx.fill();
return c;
}
/** Transparent-background billboard placeholder for items/enemies/pickups. */
function makeSpritePlaceholder(def, kind) {
const [c, ctx] = makeCanvas();
const S = TEX_SIZE;
ctx.fillStyle = shade(def.color, 1);
ctx.strokeStyle = shade(def.color, 0.5);
ctx.lineWidth = Math.max(2, S / 32);
if (kind === 'enemy') {
// Simple humanoid: head + torso + legs.
ctx.beginPath();
ctx.arc(S / 2, S * 0.2, S * 0.12, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillRect(S * 0.34, S * 0.32, S * 0.32, S * 0.36);
ctx.strokeRect(S * 0.34, S * 0.32, S * 0.32, S * 0.36);
ctx.fillRect(S * 0.36, S * 0.68, S * 0.1, S * 0.3);
ctx.fillRect(S * 0.54, S * 0.68, S * 0.1, S * 0.3);
} else if (kind === 'item') {
// Blob on a base — reads as "prop".
ctx.beginPath();
ctx.ellipse(S / 2, S * 0.42, S * 0.26, S * 0.3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = shade(def.color, 0.65);
ctx.fillRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28);
ctx.strokeRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28);
} else {
// Pickup: floating diamond.
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.15);
ctx.lineTo(S * 0.85, S / 2);
ctx.lineTo(S / 2, S * 0.85);
ctx.lineTo(S * 0.15, S / 2);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = shade(def.color, 1.35);
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.3);
ctx.lineTo(S * 0.7, S / 2);
ctx.lineTo(S / 2, S * 0.7);
ctx.lineTo(S * 0.3, S / 2);
ctx.closePath();
ctx.fill();
}
return c;
}
/** White-tinted copy of a texture, used as the enemy hurt flash. */
function makeFlashVariant(tex) {
const [c, ctx] = makeCanvas();
ctx.drawImage(tex, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
return c;
}
// ---------------------------------------------------------------------------
// Accessors (synchronous; safe before init thanks to the embedded defaults)
// ---------------------------------------------------------------------------
/** Wall def for a tile id; unknown ids read as the Empty floor tile. */
export function getWall(id) {
return reg.walls.get(id) ?? reg.walls.get(0);
}
export function isSolid(id) {
return getWall(id).solid;
}
export function getOverlayDef(id) {
return reg.overlays.get(id) ?? null;
}
export function getItemDef(id) {
return reg.items.get(id) ?? null;
}
export function getEnemyDef(type) {
return reg.enemies.get(type) ?? null;
}
export function getPickupDef(type) {
return reg.pickups.get(type) ?? null;
}
/** Entity def by type — enemies first, then pickups (ammo/health/treasure/exit). */
export function getEntityDef(type) {
return reg.enemies.get(type) ?? reg.pickups.get(type);
}
/** The enemy type old v1 levels' generic "enemy" entities migrate to. */
export function getDefaultEnemyType() {
return reg.defaultEnemyType;
}
/** All wall defs (including Empty, id 0), sorted by id — editor dropdown order. */
export function listWalls() {
return [...reg.walls.values()].sort((a, b) => a.id - b.id);
}
export function listOverlays() {
return [...reg.overlays.values()];
}
export function listItems() {
return [...reg.items.values()];
}
export function listEnemies() {
return [...reg.enemies.values()];
}
export function listPickups() {
return [...reg.pickups.values()];
}
export function getCampaigns() {
return reg.campaigns;
}

View File

@ -1,11 +1,13 @@
import Phaser from 'phaser';
import { MenuScene } from './scenes/MenuScene.js';
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. */
/** On-screen canvas size. The raycast view is rendered internally at
* RENDER_WIDTH x RENDER_HEIGHT (currently 1:1 with this) and drawn into this
* space; the HUD is layered on top. Lower the RENDER_* constants for a
* chunkier retro look — this display size stays the same. */
export const GAME_WIDTH = 960;
export const GAME_HEIGHT = 600;
@ -20,7 +22,8 @@ const config = {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [BootScene, GameScene, HudScene],
scene: [MenuScene, BootScene, GameScene, HudScene],
};
new Phaser.Game(config);
// Exposed for debugging / automated smoke tests.
window.game = new Phaser.Game(config);

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser';
import { initRegistry } from '../../engine/registry.js';
import {
loadStagedPlaytest,
takeStagedPlaytest,
fetchManifest,
fetchLevel,
getDefaultLevel,
@ -24,14 +25,16 @@ export class BootScene extends Phaser.Scene {
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.
// Content registries (walls/enemies/… JSON + baked textures). Idempotent —
// MenuScene normally did this already; this covers playtest/deep links.
await initRegistry();
// MenuScene normally sets the campaign (including the playtest handoff)
// before starting this scene. This block is the safety net for booting
// straight into this scene without going through the menu.
if (!this.registry.has('campaign')) {
const playtest = loadStagedPlaytest();
const playtest = takeStagedPlaytest();
const files = playtest ? [] : await fetchManifest();
this.registry.set('campaign', { files, index: 0, playtest });
}

View File

@ -5,6 +5,7 @@ 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 { getItemDef } from '../../engine/registry.js';
import {
RENDER_WIDTH,
RENDER_HEIGHT,
@ -36,6 +37,17 @@ export class GameScene extends Phaser.Scene {
this.player = new Player(level.playerStart);
this.entities = level.entities.map((s) => new Entity(s));
// Decorative item props (plants, lamps, …): rendered as billboards, never
// shootable or collectable; blocking ones are handled by GameMap.isBlocked.
this.props = (level.items ?? []).flatMap((s) => {
const def = getItemDef(s.id);
if (!def) {
console.warn(`[level] Unknown item id "${s.id}" skipped.`);
return [];
}
return [{ x: s.x, y: s.y, def, active: true, isEnemy: false }];
});
// Carry health/ammo/score forward from the previous level, if any.
const carry = this.registry.get('carryStats');
if (carry) {
@ -101,7 +113,7 @@ export class GameScene extends Phaser.Scene {
this.game.events.emit('player-empty');
return;
}
if (result.killed) this.player.score += 100;
if (result.killed) this.player.score += result.hit.def.points ?? 100;
this.game.events.emit('player-fired', result);
}
@ -143,7 +155,11 @@ export class GameScene extends Phaser.Scene {
// --- Render the world. ---
this.raycaster.render(this.map, this.player.cam);
this.sprites.render(this.player.cam, this.entities, this.raycaster.zBuffer);
this.sprites.render(
this.player.cam,
[...this.entities, ...this.props],
this.raycaster.zBuffer,
);
this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU
}

View File

@ -42,26 +42,36 @@ export class HudScene extends Phaser.Scene {
.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);
this.victoryShown = false; // scene instances persist; reset per run
// Clean up listeners if this scene shuts down.
// Event wiring from the game.
const handlers = {
'player-fired': (result) => this.onFired(result),
'player-empty': () => this.flash('*click* out of ammo'),
'player-pickup': (type) => this.flash(`Picked up ${type}`),
'level-complete': () => this.flash('Level complete!'),
'game-won': (score) => this.showVictory(score),
};
const events = this.gameScene.game.events;
for (const [name, fn] of Object.entries(handlers)) events.on(name, fn);
// Remove every listener on shutdown so a relaunched HUD (after returning
// to the menu) doesn't stack duplicates on the global emitter.
this.events.once('shutdown', () => {
this.gameScene.game.events.off('player-fired', this.onFired, this);
for (const [name, fn] of Object.entries(handlers)) events.off(name, fn);
});
}
/** Full-screen "you escaped" overlay shown after the final level. */
showVictory(score) {
if (this.victoryShown) return; // guard against duplicate game-won events
this.victoryShown = true;
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!', {
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, 'YOU ESCAPED!', {
fontFamily: 'monospace',
fontSize: '48px',
color: '#00ff88',
@ -69,20 +79,49 @@ export class HudScene extends Phaser.Scene {
.setOrigin(0.5)
.setDepth(11);
this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 34, `Final score: ${score}`, {
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 14, `Final score: ${score}`, {
fontFamily: 'monospace',
fontSize: '24px',
color: '#ffffff',
})
.setOrigin(0.5)
.setDepth(11);
const menuBtn = this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 80, '[ Return to Menu ]', {
fontFamily: 'monospace',
fontSize: '22px',
color: '#ffe066',
})
.setOrigin(0.5)
.setDepth(11)
.setInteractive({ useHandCursor: true });
menuBtn.on('pointerover', () => menuBtn.setColor('#ffffff'));
menuBtn.on('pointerout', () => menuBtn.setColor('#ffe066'));
menuBtn.on('pointerdown', () => this.returnToMenu());
// Free the mouse so the menu button (and the menu itself) is clickable.
this.input.mouse?.releasePointerLock();
}
/** Tear down the finished run and go back to campaign selection. */
returnToMenu() {
this.registry.remove('campaign');
this.registry.remove('carryStats');
this.registry.remove('currentLevel');
this.scene.stop('GameScene');
this.scene.start('MenuScene');
this.scene.stop(); // BootScene relaunches a fresh HUD on the next run
}
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');
if (result.killed) {
const def = result.hit.def;
this.flash(`${def.name} down! +${def.points ?? 100}`);
}
}
flash(msg) {

View File

@ -0,0 +1,96 @@
import Phaser from 'phaser';
import { initRegistry, getCampaigns } from '../../engine/registry.js';
import { takeStagedPlaytest } from '../../engine/levelIO.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
/**
* Main menu (stub): title + one selectable row per campaign from
* src/config/campaigns.json. Picking a campaign stores it in the registry and
* hands off to BootScene. A staged editor Play Test bypasses the menu entirely
* so the editor's "exactly what you painted" handoff stays one click.
*/
export class MenuScene extends Phaser.Scene {
constructor() {
super('MenuScene');
}
async create() {
await initRegistry();
// A staged Play Test (one-shot: consumed here) skips the menu entirely.
const playtest = takeStagedPlaytest();
if (playtest) {
this.registry.set('campaign', { files: [], index: 0, playtest });
this.scene.start('BootScene');
return;
}
const cx = GAME_WIDTH / 2;
this.add
.text(cx, 110, 'ESCAPE', {
fontFamily: 'monospace',
fontSize: '72px',
color: '#00ff88',
})
.setOrigin(0.5);
this.add
.text(cx, 170, 'a raycaster', {
fontFamily: 'monospace',
fontSize: '18px',
color: '#9aa0a6',
})
.setOrigin(0.5);
this.add
.text(cx, 230, 'CHOOSE YOUR CAMPAIGN', {
fontFamily: 'monospace',
fontSize: '20px',
color: '#ffe066',
})
.setOrigin(0.5);
const campaigns = getCampaigns();
campaigns.forEach((campaign, i) => {
const y = 300 + i * 90;
const name = this.add
.text(cx, y, campaign.name, {
fontFamily: 'monospace',
fontSize: '32px',
color: '#ffffff',
})
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
this.add
.text(cx, y + 30, campaign.description, {
fontFamily: 'monospace',
fontSize: '14px',
color: '#9aa0a6',
})
.setOrigin(0.5);
name.on('pointerover', () => name.setColor('#ffe066'));
name.on('pointerout', () => name.setColor('#ffffff'));
name.on('pointerdown', () => this.startCampaign(campaign));
});
if (campaigns.length === 0) {
this.add
.text(cx, GAME_HEIGHT / 2, 'No campaigns configured\n(src/config/campaigns.json)', {
fontFamily: 'monospace',
fontSize: '20px',
color: '#ff6060',
align: 'center',
})
.setOrigin(0.5);
}
}
startCampaign(campaign) {
this.registry.set('campaign', {
files: campaign.levels,
index: 0,
playtest: null,
});
this.registry.remove('carryStats'); // fresh health/ammo/score
this.scene.start('BootScene');
}
}

42
src/levels/c2_level1.json Normal file
View File

@ -0,0 +1,42 @@
{
"version": 2,
"name": "The Depths — Entrance",
"width": 16,
"height": 16,
"tiles": [
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 4, 0, 0, 0, 0, 0, 0, 4, 4, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2
],
"overlays": [
{ "x": 3, "y": 9, "id": "banner" },
{ "x": 10, "y": 10, "id": "vines" },
{ "x": 12, "y": 2, "id": "painting1" }
],
"items": [
{ "id": "plant", "x": 6.5, "y": 11.5 },
{ "id": "lamp", "x": 2.5, "y": 2.5 },
{ "id": "rubble", "x": 8.5, "y": 12.5 }
],
"playerStart": { "x": 2.5, "y": 13.5, "dir": -1.5707963267948966 },
"entities": [
{ "type": "heavy", "x": 8.5, "y": 3.5 },
{ "type": "scout", "x": 12.5, "y": 5.5 },
{ "type": "ammo", "x": 4.5, "y": 10.5 },
{ "type": "health", "x": 13.5, "y": 13.5 },
{ "type": "exit", "x": 13.5, "y": 2.5 }
]
}

41
src/levels/c3_level1.json Normal file
View File

@ -0,0 +1,41 @@
{
"version": 2,
"name": "Rooftop Run — Takeoff",
"width": 16,
"height": 16,
"tiles": [
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 4, 4, 4, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3
],
"overlays": [
{ "x": 4, "y": 6, "id": "painting1" },
{ "x": 9, "y": 9, "id": "banner" }
],
"items": [
{ "id": "lamp", "x": 6.5, "y": 12.5 },
{ "id": "plant", "x": 11.5, "y": 3.5 }
],
"playerStart": { "x": 2.5, "y": 13.5, "dir": -1.5707963267948966 },
"entities": [
{ "type": "scout", "x": 7.5, "y": 3.5 },
{ "type": "scout", "x": 12.5, "y": 8.5 },
{ "type": "guard", "x": 5.5, "y": 7.5 },
{ "type": "ammo", "x": 2.5, "y": 8.5 },
{ "type": "treasure", "x": 10.5, "y": 10.5 },
{ "type": "exit", "x": 14.5, "y": 1.5 }
]
}

View File

@ -2,7 +2,7 @@
// 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,
"version": 2,
"name": "Level 1 — Sandbox",
"width": 16,
"height": 16,
@ -24,10 +24,20 @@ export default {
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
],
"overlays": [
{ "x": 8, "y": 2, "id": "painting1" },
{ "x": 3, "y": 9, "id": "banner" },
{ "x": 12, "y": 2, "id": "vines" }
],
"items": [
{ "id": "plant", "x": 2.5, "y": 5.5 },
{ "id": "lamp", "x": 10.5, "y": 13.5 },
{ "id": "rubble", "x": 6.5, "y": 10.5 }
],
"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": "guard", "x": 6.5, "y": 3.5 },
{ "type": "scout", "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 },

View File

@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"name": "Level 1 — Sandbox",
"width": 16,
"height": 16,
@ -21,10 +21,20 @@
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
],
"overlays": [
{ "x": 8, "y": 2, "id": "painting1" },
{ "x": 3, "y": 9, "id": "banner" },
{ "x": 12, "y": 2, "id": "vines" }
],
"items": [
{ "id": "plant", "x": 2.5, "y": 5.5 },
{ "id": "lamp", "x": 10.5, "y": 13.5 },
{ "id": "rubble", "x": 6.5, "y": 10.5 }
],
"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": "guard", "x": 6.5, "y": 3.5 },
{ "type": "scout", "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 },

View File

@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"name": "Level 2 — The Gauntlet",
"width": 16,
"height": 16,
@ -21,12 +21,20 @@
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
],
"overlays": [
{ "x": 5, "y": 2, "id": "banner" },
{ "x": 6, "y": 6, "id": "vines" }
],
"items": [
{ "id": "plant", "x": 1.5, "y": 5.5 },
{ "id": "rubble", "x": 9.5, "y": 14.5 }
],
"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": "guard", "x": 4.5, "y": 1.5 },
{ "type": "scout", "x": 12.5, "y": 1.5 },
{ "type": "heavy", "x": 7.5, "y": 7.5 },
{ "type": "guard", "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 },