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
|
|
@ -0,0 +1,2 @@
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
158
README.md
|
|
@ -1,9 +1,11 @@
|
||||||
# Escape
|
# Escape
|
||||||
|
|
||||||
A Wolfenstein-3D-style raycaster game built with **Phaser 4**. The "3D" is an
|
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.
|
authentic raycasting engine: a 2D grid rendered as vertical wall strips, with
|
||||||
Graphics are intentionally stubbed (solid colors / placeholder shapes) so you
|
textured walls, wall overlays (paintings etc.), decorative items, and multiple
|
||||||
can drop in your own spritesheets later.
|
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
|
## Run
|
||||||
|
|
||||||
|
|
@ -36,88 +38,137 @@ Then open:
|
||||||
| Turn | `←` `→`, or **mouse** (click to lock) |
|
| Turn | `←` `→`, or **mouse** (click to lock) |
|
||||||
| Shoot | **Left-click** (mouse locked) / `Space` |
|
| Shoot | **Left-click** (mouse locked) / `Space` |
|
||||||
|
|
||||||
The HUD shows health, ammo, and score. Walk over pickups to collect them; shoot
|
The game opens on a **main menu** listing the campaigns from
|
||||||
guards to score. Click the view once to capture the mouse for look controls.
|
`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`)
|
## Level editor (`/editor.html`)
|
||||||
|
|
||||||
- **Palette** (top): pick a wall tile, the player **Start** marker, or an entity.
|
- **Palette** (top): dropdowns for **Wall / Overlay / Item / Enemy** (populated
|
||||||
- **Left-click / drag**: paint the selected brush.
|
from the config JSONs) plus **Start**, pickup, and **Erase** tool buttons.
|
||||||
- **Right-click / drag**: erase (clears the tile and any entity in the cell).
|
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.
|
- **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`
|
- **New**: start a fresh blank grid (default 72×72; see `NEW_LEVEL_WIDTH/HEIGHT`
|
||||||
in `src/engine/constants.js`). **Save**: persist to browser storage.
|
in `src/engine/constants.js`). **Save**: persist to browser storage.
|
||||||
**Export/Import JSON**: files on disk.
|
**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
|
In the grid: overlays show as a corner triangle on their wall cell, items are
|
||||||
drawn, so bigger maps stay fast. Scroll with the arrow keys as your level grows
|
squares, enemies are large circles, pickups small circles, and the player start
|
||||||
past the visible area.
|
is the blue arrow.
|
||||||
|
|
||||||
Place an **Exit** entity in a level so the player can finish it; walking onto the
|
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
|
exit advances to the next level in the campaign. A level with no exit instead
|
||||||
instead completes when every guard is defeated.
|
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
|
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 game reads. To author levels, paint one in the editor, Export it, drop the
|
||||||
the file in `src/levels/` (see the campaign section). The format is:
|
file in `src/levels/`, and list it in a campaign in `src/config/campaigns.json`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"version": 1, // schema version (stamped on export)
|
"version": 2, // schema version (stamped on export)
|
||||||
"name": "Dungeon Entrance", // display name; also the export filename
|
"name": "Dungeon Entrance", // display name; also the export filename
|
||||||
"width": 16, // grid columns
|
"width": 16, // grid columns
|
||||||
"height": 16, // grid rows
|
"height": 16, // grid rows
|
||||||
"tiles": [ /* … */ ], // row-major ints, length MUST equal width*height
|
"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
|
"playerStart": { "x": 2.5, "y": 3.5, "dir": 0 }, // cell units; dir in radians
|
||||||
"entities": [ // may be empty
|
"entities": [ // enemies (types from enemies.json) + pickups
|
||||||
{ "type": "enemy", "x": 8.5, "y": 5.5 },
|
{ "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": "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 }
|
{ "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`) and **v1 files migrate
|
||||||
Files are validated on load (`parseLevel`): `tiles.length` must equal
|
automatically** (generic `"enemy"` becomes `enemies.json`'s `defaultType`;
|
||||||
`width*height` and `playerStart` must be present; unknown tile/entity ids fail
|
missing `overlays`/`items` default to empty). Unknown ids fail safe rather
|
||||||
safe rather than crashing.
|
than crashing.
|
||||||
|
|
||||||
## Campaign — using your files as game levels
|
## Campaigns
|
||||||
|
|
||||||
The game plays the level files listed, in order, in the manifest
|
The menu lists the campaigns in `src/config/campaigns.json`; each plays its
|
||||||
`src/levels/levels.json`:
|
`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
|
`src/levels/levels.json` remains only as a legacy fallback manifest for booting
|
||||||
{
|
the game scene directly without the menu.
|
||||||
"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
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html Game page (import map → vendored Phaser)
|
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)
|
vendor/ phaser.esm.min.js (vendored so no npm install is needed)
|
||||||
|
assets/ Your spritesheets (optional — placeholders bake if absent)
|
||||||
src/
|
src/
|
||||||
engine/ Shared core: constants (TILE & entity registries), GameMap,
|
config/ walls.json, overlays.json, items.json, enemies.json, campaigns.json
|
||||||
Raycaster (DDA + ZBuffer), SpriteRenderer, Player, Entity, levelIO
|
engine/ Shared core: constants, registry (JSON configs + texture baking),
|
||||||
game/ Phaser bootstrap + Boot/Game/Hud scenes
|
GameMap, Raycaster (DDA + textures + overlays + ZBuffer),
|
||||||
|
SpriteRenderer, Player, Entity, levelIO
|
||||||
|
game/ Phaser bootstrap + Menu/Boot/Game/Hud scenes
|
||||||
editor/ Phaser bootstrap + EditorScene
|
editor/ Phaser bootstrap + EditorScene
|
||||||
levels/ levels.json (campaign manifest), level1.json / level2.json (levels),
|
levels/ level files (+ level1.data.js, the bundled offline fallback kept
|
||||||
level1.data.js (bundled offline fallback, kept in sync with level1.json)
|
in sync with level1.json; levels.json, the legacy manifest)
|
||||||
```
|
```
|
||||||
|
|
||||||
The game and editor are separate HTML entry points that share everything in
|
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
|
## Where to extend next
|
||||||
|
|
||||||
- **Textures**: swap the solid-color strip in `engine/Raycaster.js` and the
|
- **Real art**: drop spritesheets at the paths named in `src/config/*.json`
|
||||||
billboard `fillRect` in `engine/SpriteRenderer.js` for `drawImage` of your
|
(see `assets/README.md`) — zero code changes.
|
||||||
spritesheet frames. Tile ids already map 1:1 to a texture index.
|
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack); each
|
||||||
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack).
|
enemy def already carries a data-driven `speed`.
|
||||||
- **Doors / keys**: tile id 5 is flagged `isDoor` in `engine/constants.js`;
|
- **Doors / keys**: door tiles are flagged `isDoor` in `walls.json`;
|
||||||
wire sliding + locked-door logic into `GameMap`/`Raycaster`.
|
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`.
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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.
|
||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 235 KiB |
60
editor.html
|
|
@ -53,6 +53,54 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9aa0a6;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -66,6 +114,18 @@
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
</div>
|
</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>
|
<div id="editor"></div>
|
||||||
<input id="file-input" type="file" accept="application/json" style="display: none" />
|
<input id="file-input" type="file" accept="application/json" style="display: none" />
|
||||||
<!-- Resolve the bare "phaser" import to the vendored ESM build so no
|
<!-- Resolve the bare "phaser" import to the vendored ESM build so no
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { GameMap } from '../engine/GameMap.js';
|
import { GameMap } from '../engine/GameMap.js';
|
||||||
import {
|
import {
|
||||||
TILES,
|
getWall,
|
||||||
ENTITIES,
|
|
||||||
MAX_TILE_ID,
|
|
||||||
getTile,
|
|
||||||
getEntityDef,
|
getEntityDef,
|
||||||
} from '../engine/constants.js';
|
getItemDef,
|
||||||
|
getOverlayDef,
|
||||||
|
listWalls,
|
||||||
|
listOverlays,
|
||||||
|
listItems,
|
||||||
|
listEnemies,
|
||||||
|
listPickups,
|
||||||
|
} from '../engine/registry.js';
|
||||||
import {
|
import {
|
||||||
loadEditorLevel,
|
loadEditorLevel,
|
||||||
saveEditorLevel,
|
saveEditorLevel,
|
||||||
|
|
@ -16,7 +20,7 @@ import {
|
||||||
createBlankLevel,
|
createBlankLevel,
|
||||||
} from '../engine/levelIO.js';
|
} 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 MIN_CELL = 12;
|
||||||
const MAX_CELL = 64;
|
const MAX_CELL = 64;
|
||||||
const SCROLL_SPEED = 900; // px/sec for arrow-key scrolling
|
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
|
* 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
|
* (GameMap + the JSON-driven registry) so what you paint is exactly what
|
||||||
* with left-click, erase with right-click, pan with middle-drag, zoom with the
|
* plays. The palette is a row of dropdowns — one per content type (walls,
|
||||||
* wheel. Save / Export / Import / Play Test are wired to the DOM toolbar.
|
* 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 {
|
export class EditorScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('EditorScene');
|
super('EditorScene');
|
||||||
// Brush is one of:
|
// Brush is one of:
|
||||||
// { kind: 'tile', id } | { kind: 'start' } | { kind: 'entity', type }
|
// { kind: 'wall', id } paint tile id (0 = floor)
|
||||||
this.brush = { kind: 'tile', id: 1 };
|
// { 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.cellSize = 32;
|
||||||
this.offsetX = 40;
|
this.offsetX = 40;
|
||||||
this.offsetY = TOP_MARGIN + 20;
|
this.offsetY = TOP_MARGIN + 20;
|
||||||
|
|
@ -60,50 +72,85 @@ export class EditorScene extends Phaser.Scene {
|
||||||
// --- DOM palette + toolbar ------------------------------------------------
|
// --- DOM palette + toolbar ------------------------------------------------
|
||||||
|
|
||||||
buildPalette() {
|
buildPalette() {
|
||||||
const toolbar = document.getElementById('toolbar');
|
const controls = [];
|
||||||
const palette = document.createElement('div');
|
const setActive = (el, brush) => {
|
||||||
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;
|
this.brush = brush;
|
||||||
for (const b of buttons) b.style.outline = 'none';
|
for (const c of controls) c.classList.remove('active-tool');
|
||||||
btn.style.outline = '2px solid #ffe066';
|
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');
|
const btn = document.createElement('button');
|
||||||
btn.textContent = label;
|
btn.textContent = def.name;
|
||||||
btn.style.cssText =
|
btn.style.background = cssColor(def.color);
|
||||||
`background:${cssColor(color)};color:#000;border:1px solid #000;` +
|
btn.style.color = '#000';
|
||||||
'border-radius:3px;padding:4px 8px;cursor:pointer;font-size:12px;';
|
pickupSpan.appendChild(btn);
|
||||||
btn.onclick = () => select(btn, brush);
|
wireButton(btn, { kind: 'pickup', type: def.type });
|
||||||
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
|
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() {
|
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() {
|
syncLevel() {
|
||||||
this.level.tiles = this.map.getTiles();
|
this.level.tiles = this.map.getTiles();
|
||||||
|
this.level.overlays = this.map.getOverlays();
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(msg) {
|
setStatus(msg) {
|
||||||
|
|
@ -196,22 +246,45 @@ export class EditorScene extends Phaser.Scene {
|
||||||
const cell = this.cellUnder(p);
|
const cell = this.cellUnder(p);
|
||||||
if (!cell) return;
|
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()) {
|
if (p.rightButtonDown()) {
|
||||||
this.map.setTile(cell.x, cell.y, 0);
|
this.eraseCell(cell);
|
||||||
this.removeEntityAt(cell.x, cell.y);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (this.brush.kind) {
|
switch (this.brush.kind) {
|
||||||
case 'tile':
|
case 'wall':
|
||||||
this.map.setTile(cell.x, cell.y, this.brush.id);
|
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;
|
break;
|
||||||
case 'start':
|
case 'overlay':
|
||||||
this.level.playerStart.x = cell.x + 0.5;
|
if (!this.map.isWall(cell.x, cell.y)) {
|
||||||
this.level.playerStart.y = cell.y + 0.5;
|
this.setStatus('Overlays go on wall cells — paint a wall first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.map.setOverlay(cell.x, cell.y, this.brush.id);
|
||||||
break;
|
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.removeEntityAt(cell.x, cell.y);
|
||||||
this.level.entities.push({
|
this.level.entities.push({
|
||||||
type: this.brush.type,
|
type: this.brush.type,
|
||||||
|
|
@ -219,26 +292,70 @@ export class EditorScene extends Phaser.Scene {
|
||||||
y: cell.y + 0.5,
|
y: cell.y + 0.5,
|
||||||
});
|
});
|
||||||
break;
|
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) {
|
removeEntityAt(cx, cy) {
|
||||||
this.level.entities = this.level.entities.filter(
|
this.level.entities = this.level.entities.filter(
|
||||||
(e) => Math.floor(e.x) !== cx || Math.floor(e.y) !== cy,
|
(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) {
|
updateHoverStatus(p) {
|
||||||
const cell = this.cellUnder(p);
|
const cell = this.cellUnder(p);
|
||||||
const brushLabel =
|
if (!cell) {
|
||||||
this.brush.kind === 'tile'
|
this.setStatus(`Brush: ${this.brushLabel()}`);
|
||||||
? `tile:${getTile(this.brush.id).name}`
|
return;
|
||||||
: this.brush.kind === 'start'
|
}
|
||||||
? 'player start'
|
// Name what's under the cursor so overlays/items are easy to inspect.
|
||||||
: `entity:${this.brush.type}`;
|
const parts = [`Cell ${cell.x},${cell.y}`];
|
||||||
this.setStatus(
|
const overlay = this.map.overlayAt(cell.x, cell.y);
|
||||||
cell ? `Cell ${cell.x},${cell.y} | Brush: ${brushLabel}` : `Brush: ${brushLabel}`,
|
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 ------------------------------------------------------------
|
// --- Scrolling ------------------------------------------------------------
|
||||||
|
|
@ -286,13 +403,21 @@ export class EditorScene extends Phaser.Scene {
|
||||||
const minY = Math.max(0, Math.floor((TOP_MARGIN - oy) / 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));
|
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 y = minY; y <= maxY; y++) {
|
||||||
for (let x = minX; x <= maxX; x++) {
|
for (let x = minX; x <= maxX; x++) {
|
||||||
const id = this.map.tileAt(x, y);
|
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.fillStyle(color, 1);
|
||||||
g.fillRect(ox + x * cs, oy + y * cs, cs - 1, cs - 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);
|
g.lineBetween(gy0, oy + y * cs, gy1, oy + y * cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entities (colored dots), skipping any scrolled off screen.
|
const onScreen = (e) =>
|
||||||
for (const e of this.level.entities) {
|
e.x >= minX - 1 && e.x <= maxX + 1 && e.y >= minY - 1 && e.y <= maxY + 1;
|
||||||
if (e.x < minX - 1 || e.x > maxX + 1 || e.y < minY - 1 || e.y > maxY + 1) continue;
|
|
||||||
const def = getEntityDef(e.type);
|
// 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.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).
|
// Player start (blue arrow in facing direction).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { initRegistry } from '../engine/registry.js';
|
||||||
import { EditorScene } from './EditorScene.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
|
// 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.
|
// lets it follow the browser size; the toolbar is plain DOM in editor.html.
|
||||||
const config = {
|
const config = {
|
||||||
|
|
@ -15,4 +21,5 @@ const config = {
|
||||||
scene: [EditorScene],
|
scene: [EditorScene],
|
||||||
};
|
};
|
||||||
|
|
||||||
new Phaser.Game(config);
|
// Exposed for debugging / automated smoke tests.
|
||||||
|
window.editorGame = new Phaser.Game(config);
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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';
|
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. */
|
/** Apply damage (enemies only). Returns true if this shot killed it. */
|
||||||
damage(amount) {
|
damage(amount) {
|
||||||
if (!this.isEnemy || !this.active) return false;
|
if (!this.isEnemy || !this.active) return false;
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
/** Runtime wrapper around a LevelData grid. Shared by the game (collision +
|
||||||
* raycasting) and the editor (paint/erase). Keeps all grid-index math in one
|
* raycasting) and the editor (paint/erase). Keeps all grid-index math in one
|
||||||
|
|
@ -9,6 +9,23 @@ export class GameMap {
|
||||||
this.height = level.height;
|
this.height = level.height;
|
||||||
// Defensive copy so mutations (editor painting) don't alias the source.
|
// Defensive copy so mutations (editor painting) don't alias the source.
|
||||||
this.tiles = level.tiles.slice();
|
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) {
|
inBounds(x, y) {
|
||||||
|
|
@ -31,17 +48,50 @@ export class GameMap {
|
||||||
this.tiles[cy * this.width + cx] = id;
|
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) {
|
isWall(x, y) {
|
||||||
return isSolid(this.tileAt(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) {
|
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). */
|
/** Snapshot the grid back into a plain array (for serialization). */
|
||||||
getTiles() {
|
getTiles() {
|
||||||
return this.tiles.slice();
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,15 +47,16 @@ export class Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try to move to (nx, ny), sliding along walls using a small radius so the
|
/** 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) {
|
tryMove(map, nx, ny) {
|
||||||
const { cam } = this;
|
const { cam } = this;
|
||||||
const r = PLAYER_RADIUS;
|
const r = PLAYER_RADIUS;
|
||||||
// Resolve each axis independently for wall-sliding.
|
// 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;
|
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;
|
cam.posY = ny;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,26 @@ import {
|
||||||
RENDER_HEIGHT,
|
RENDER_HEIGHT,
|
||||||
CEILING_COLOR,
|
CEILING_COLOR,
|
||||||
FLOOR_COLOR,
|
FLOOR_COLOR,
|
||||||
getTile,
|
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
import { getWall, TEX_SIZE } from './registry.js';
|
||||||
|
|
||||||
/** Split an 0xRRGGBB color into [r, g, b]. */
|
/** Split an 0xRRGGBB color into [r, g, b]. */
|
||||||
function rgb(color) {
|
function rgb(color) {
|
||||||
return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff];
|
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
|
* DDA raycaster. For each screen column it marches the grid to the nearest
|
||||||
* canvas context and records the per-column wall distance in a ZBuffer so the
|
* solid wall, then draws a 1px-wide vertical strip of that wall's texture
|
||||||
* sprite renderer can occlude billboards correctly.
|
* (a baked canvas from the registry — real spritesheet frame or placeholder),
|
||||||
*
|
* composites the cell's overlay texture (painting/banner/…) over it, and
|
||||||
* This deliberately uses solid colors (no textures) for the first pass; the
|
* finally applies shading as a translucent black rect. The per-column wall
|
||||||
* per-column loop is the exact place texture mapping slots in later.
|
* distance is recorded in a ZBuffer so the sprite renderer can occlude
|
||||||
|
* billboards correctly.
|
||||||
*/
|
*/
|
||||||
export class Raycaster {
|
export class Raycaster {
|
||||||
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
|
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
|
||||||
|
|
@ -39,6 +44,10 @@ export class Raycaster {
|
||||||
ctx.fillStyle = `rgb(${fr},${fg},${fb})`;
|
ctx.fillStyle = `rgb(${fr},${fg},${fb})`;
|
||||||
ctx.fillRect(0, height / 2, width, height / 2);
|
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++) {
|
for (let x = 0; x < width; x++) {
|
||||||
// cameraX ranges -1..1 across the screen; project onto the camera plane.
|
// cameraX ranges -1..1 across the screen; project onto the camera plane.
|
||||||
const cameraX = (2 * x) / width - 1;
|
const cameraX = (2 * x) / width - 1;
|
||||||
|
|
@ -89,32 +98,66 @@ export class Raycaster {
|
||||||
side = 1;
|
side = 1;
|
||||||
}
|
}
|
||||||
tileId = map.tileAt(mapX, mapY);
|
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.
|
// Perpendicular distance (avoids fisheye) = how far along the ray we are.
|
||||||
const perpDist =
|
const perpDist =
|
||||||
side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
|
side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
|
||||||
this.zBuffer[x] = perpDist;
|
this.zBuffer[x] = perpDist;
|
||||||
|
|
||||||
const lineHeight = Math.floor(height / perpDist);
|
const lineHeight = Math.min(
|
||||||
let drawStart = Math.floor(-lineHeight / 2 + height / 2);
|
Math.floor(height / perpDist),
|
||||||
let drawEnd = Math.floor(lineHeight / 2 + height / 2);
|
height * MAX_LINE_FACTOR,
|
||||||
if (drawStart < 0) drawStart = 0;
|
);
|
||||||
if (drawEnd >= height) drawEnd = height - 1;
|
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
|
// Texture x-coordinate: where along the wall face the ray hit (0..1).
|
||||||
// distance so depth reads clearly even without textures.
|
let wallX =
|
||||||
let [r, g, b] = rgb(getTile(tileId).color);
|
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 sideMul = side === 1 ? 0.65 : 1.0;
|
||||||
const distMul = Math.max(0.25, 1 - perpDist / 16);
|
const distMul = Math.max(0.25, 1 - perpDist / 16);
|
||||||
const m = sideMul * distMul;
|
const m = sideMul * distMul;
|
||||||
r = (r * m) | 0;
|
if (m < 0.995) {
|
||||||
g = (g * m) | 0;
|
ctx.fillStyle = `rgba(0,0,0,${(1 - m).toFixed(3)})`;
|
||||||
b = (b * m) | 0;
|
|
||||||
|
|
||||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
||||||
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart + 1);
|
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import { RENDER_WIDTH, RENDER_HEIGHT } from './constants.js';
|
import { RENDER_WIDTH, RENDER_HEIGHT } from './constants.js';
|
||||||
|
import { 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];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Billboard sprite renderer. Projects each entity into camera space, sorts
|
* Billboard sprite renderer. Projects each renderable (enemy, pickup, or
|
||||||
* back-to-front, and draws it as a screen-space quad clipped per-column against
|
* decorative item) into camera space, sorts back-to-front, and draws its
|
||||||
* the raycaster's ZBuffer so walls correctly occlude sprites.
|
* 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
|
* A renderable needs: x, y, active, isEnemy, and a def with tex (+ optional
|
||||||
* frames means replacing the inner fillRect with a drawImage of the chosen
|
* texFlash and scale). Entities and GameScene's item props both fit.
|
||||||
* frame, using the same projected rect.
|
|
||||||
*/
|
*/
|
||||||
export class SpriteRenderer {
|
export class SpriteRenderer {
|
||||||
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
|
constructor(ctx, width = RENDER_WIDTH, height = RENDER_HEIGHT) {
|
||||||
|
|
@ -21,9 +17,9 @@ export class SpriteRenderer {
|
||||||
this.height = height;
|
this.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(cam, entities, zBuffer) {
|
render(cam, renderables, zBuffer) {
|
||||||
// Sort far-to-near so nearer sprites paint over farther ones.
|
// Sort far-to-near so nearer sprites paint over farther ones.
|
||||||
const visible = entities
|
const visible = renderables
|
||||||
.filter((e) => e.active)
|
.filter((e) => e.active)
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
e,
|
e,
|
||||||
|
|
@ -45,30 +41,38 @@ export class SpriteRenderer {
|
||||||
|
|
||||||
const screenX = Math.floor((this.width / 2) * (1 + transformX / transformY));
|
const screenX = Math.floor((this.width / 2) * (1 + transformX / transformY));
|
||||||
|
|
||||||
// Pickups sit lower and smaller than enemies for a touch of variety.
|
// Enemies are full wall height; items/pickups use their def's scale.
|
||||||
const scale = e.isEnemy ? 1.0 : 0.5;
|
const scale = e.isEnemy ? 1.0 : (e.def?.scale ?? 0.5);
|
||||||
const spriteH = Math.abs(Math.floor(this.height / transformY)) * scale;
|
const fullH = this.height / transformY;
|
||||||
|
const spriteH = Math.abs(Math.floor(fullH)) * scale;
|
||||||
const spriteW = spriteH; // square billboards
|
const spriteW = spriteH; // square billboards
|
||||||
|
if (spriteH < 1) continue; // too far away to draw
|
||||||
|
|
||||||
// Anchor pickups to the floor; center enemies vertically.
|
// Anchor items/pickups to the floor; center enemies vertically.
|
||||||
const vMove = e.isEnemy ? 0 : this.height / transformY / 2;
|
const vMove = e.isEnemy ? 0 : fullH / 2 - spriteH / 2;
|
||||||
const centerY = this.height / 2 + vMove;
|
const centerY = this.height / 2 + vMove;
|
||||||
let drawStartY = Math.floor(centerY - spriteH / 2);
|
// Unclamped bounds — the canvas clips vertically, keeping the 1:1
|
||||||
let drawEndY = Math.floor(centerY + spriteH / 2);
|
// mapping between source texture rows and on-screen pixels.
|
||||||
if (drawStartY < 0) drawStartY = 0;
|
const drawStartY = Math.floor(centerY - spriteH / 2);
|
||||||
if (drawEndY >= this.height) drawEndY = this.height - 1;
|
|
||||||
|
|
||||||
const drawStartX = Math.floor(screenX - spriteW / 2);
|
const drawStartX = Math.floor(screenX - spriteW / 2);
|
||||||
const drawEndX = Math.floor(screenX + spriteW / 2);
|
const drawEndX = Math.floor(screenX + spriteW / 2);
|
||||||
|
|
||||||
const [r, g, b] = rgb(e.renderColor);
|
const tex = e.state === 'hurt' ? e.def.texFlash : e.def?.tex;
|
||||||
this.ctx.fillStyle = `rgb(${r},${g},${b})`;
|
if (!tex) continue; // registry not baked yet (first frames / tests)
|
||||||
|
|
||||||
// Per-column occlusion: only draw stripes nearer than the wall there.
|
// Per-column occlusion: only draw stripes nearer than the wall there.
|
||||||
for (let stripe = drawStartX; stripe <= drawEndX; stripe++) {
|
for (let stripe = drawStartX; stripe <= drawEndX; stripe++) {
|
||||||
if (stripe < 0 || stripe >= this.width) continue;
|
if (stripe < 0 || stripe >= this.width) continue;
|
||||||
if (transformY >= zBuffer[stripe]) continue; // wall is in front
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
// Render configuration
|
// Render configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Internal render resolution of the raycast view. We render the world at this
|
/** Internal render resolution of the raycast view. Originally 320×200 for the
|
||||||
* low resolution and upscale the result for the chunky retro look. */
|
* chunky retro look; now tripled — 1:1 with the 960×600 display canvas (still
|
||||||
export const RENDER_WIDTH = 320;
|
* upscaled cleanly on larger windows). Drop these back down for more chunk,
|
||||||
export const RENDER_HEIGHT = 200;
|
* 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
|
/** 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. */
|
* 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;
|
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 = {
|
export const DOOR_SLIDE_MS = 900; // time for a door to slide fully open/closed
|
||||||
0: { id: 0, name: 'Empty', solid: false, isDoor: false, color: 0x000000 },
|
export const DOOR_OPEN_MS = 4000; // how long a door stays open before closing
|
||||||
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)
|
// Content registries
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Drives both the editor palette and the in-game spawner. `color` is the stub
|
// Wall tiles, overlays, items, enemies, and campaigns are all data-driven:
|
||||||
// billboard color until real sprites are supplied.
|
// 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 = {
|
/** Folder (relative to the HTML page) holding the content config JSON files
|
||||||
enemy: { type: 'enemy', name: 'Guard', color: 0xb03030, isEnemy: true, health: 50 },
|
* (walls.json, overlays.json, items.json, enemies.json, campaigns.json). */
|
||||||
ammo: { type: 'ammo', name: 'Ammo', color: 0xd0d040, isEnemy: false, health: 0 },
|
export const CONFIG_DIR = './src/config/';
|
||||||
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
|
// Level file format + campaign manifest
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Schema version stamped into exported level files. Bump when the on-disk
|
/** Schema version stamped into exported level files. Bump when the on-disk
|
||||||
* shape changes so old files can be detected/upgraded. */
|
* shape changes so old files can be detected/upgraded.
|
||||||
export const LEVEL_FORMAT_VERSION = 1;
|
* 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
|
/** 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
|
* old 16x16 grid so you can build big levels; scroll with the arrow keys or a
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,35 @@ import {
|
||||||
NEW_LEVEL_WIDTH,
|
NEW_LEVEL_WIDTH,
|
||||||
NEW_LEVEL_HEIGHT,
|
NEW_LEVEL_HEIGHT,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
import { getDefaultEnemyType } from './registry.js';
|
||||||
import defaultLevel from '../levels/level1.data.js';
|
import defaultLevel from '../levels/level1.data.js';
|
||||||
|
|
||||||
/** The bundled fallback level, deep-cloned so callers can mutate freely. */
|
/** The bundled fallback level, deep-cloned so callers can mutate freely. */
|
||||||
export function getDefaultLevel() {
|
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 —
|
/** An empty level with a solid border wall and the player start in the middle —
|
||||||
|
|
@ -36,6 +60,8 @@ export function createBlankLevel(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles,
|
tiles,
|
||||||
|
overlays: [],
|
||||||
|
items: [],
|
||||||
playerStart: { x: Math.floor(width / 2) + 0.5, y: Math.floor(height / 2) + 0.5, dir: 0 },
|
playerStart: { x: Math.floor(width / 2) + 0.5, y: Math.floor(height / 2) + 0.5, dir: 0 },
|
||||||
entities: [],
|
entities: [],
|
||||||
};
|
};
|
||||||
|
|
@ -53,11 +79,9 @@ export function parseLevel(raw) {
|
||||||
data.tiles.length === data.width * data.height &&
|
data.tiles.length === data.width * data.height &&
|
||||||
data.playerStart != null
|
data.playerStart != null
|
||||||
) {
|
) {
|
||||||
// Fill in optional fields so downstream code can assume they exist.
|
// Upgrade old versions + fill in optional fields so downstream code
|
||||||
data.version ??= LEVEL_FORMAT_VERSION;
|
// can assume they exist.
|
||||||
data.entities ??= [];
|
return migrateLevel(data);
|
||||||
data.name ??= 'Untitled';
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
|
|
@ -65,12 +89,15 @@ export function parseLevel(raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A "Play Test" handoff staged by the editor, or null if none is pending.
|
/** Consume the "Play Test" handoff staged by the editor: return the level (or
|
||||||
* When present it takes precedence over the campaign so the editor can test a
|
* null if none is pending) and clear it. When present it takes precedence
|
||||||
* single unsaved level. */
|
* over the campaign and bypasses the menu, so the editor can test a single
|
||||||
export function loadStagedPlaytest() {
|
* 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);
|
const pending = localStorage.getItem(PLAYTEST_KEY);
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
|
localStorage.removeItem(PLAYTEST_KEY);
|
||||||
return parseLevel(pending);
|
return parseLevel(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +161,8 @@ export function exportLevel(level) {
|
||||||
width: level.width,
|
width: level.width,
|
||||||
height: level.height,
|
height: level.height,
|
||||||
tiles: level.tiles,
|
tiles: level.tiles,
|
||||||
|
overlays: level.overlays ?? [],
|
||||||
|
items: level.items ?? [],
|
||||||
playerStart: level.playerStart,
|
playerStart: level.playerStart,
|
||||||
entities: level.entities,
|
entities: level.entities,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { MenuScene } from './scenes/MenuScene.js';
|
||||||
import { BootScene } from './scenes/BootScene.js';
|
import { BootScene } from './scenes/BootScene.js';
|
||||||
import { GameScene } from './scenes/GameScene.js';
|
import { GameScene } from './scenes/GameScene.js';
|
||||||
import { HudScene } from './scenes/HudScene.js';
|
import { HudScene } from './scenes/HudScene.js';
|
||||||
|
|
||||||
/** On-screen canvas size. The raycast view is rendered at a low internal
|
/** On-screen canvas size. The raycast view is rendered internally at
|
||||||
* resolution (RENDER_WIDTH x RENDER_HEIGHT) and upscaled into this space, so
|
* RENDER_WIDTH x RENDER_HEIGHT (currently 1:1 with this) and drawn into this
|
||||||
* the HUD stays crisp while the 3D view keeps its chunky retro look. */
|
* 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_WIDTH = 960;
|
||||||
export const GAME_HEIGHT = 600;
|
export const GAME_HEIGHT = 600;
|
||||||
|
|
||||||
|
|
@ -20,7 +22,8 @@ const config = {
|
||||||
mode: Phaser.Scale.FIT,
|
mode: Phaser.Scale.FIT,
|
||||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { initRegistry } from '../../engine/registry.js';
|
||||||
import {
|
import {
|
||||||
loadStagedPlaytest,
|
takeStagedPlaytest,
|
||||||
fetchManifest,
|
fetchManifest,
|
||||||
fetchLevel,
|
fetchLevel,
|
||||||
getDefaultLevel,
|
getDefaultLevel,
|
||||||
|
|
@ -24,14 +25,16 @@ export class BootScene extends Phaser.Scene {
|
||||||
super('BootScene');
|
super('BootScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
preload() {
|
|
||||||
// e.g. this.load.spritesheet('guard', 'assets/guard.png', { ... });
|
|
||||||
}
|
|
||||||
|
|
||||||
async create() {
|
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')) {
|
if (!this.registry.has('campaign')) {
|
||||||
const playtest = loadStagedPlaytest();
|
const playtest = takeStagedPlaytest();
|
||||||
const files = playtest ? [] : await fetchManifest();
|
const files = playtest ? [] : await fetchManifest();
|
||||||
this.registry.set('campaign', { files, index: 0, playtest });
|
this.registry.set('campaign', { files, index: 0, playtest });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Entity } from '../../engine/Entity.js';
|
||||||
import { Raycaster } from '../../engine/Raycaster.js';
|
import { Raycaster } from '../../engine/Raycaster.js';
|
||||||
import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
|
import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
|
||||||
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
|
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
|
||||||
|
import { getItemDef } from '../../engine/registry.js';
|
||||||
import {
|
import {
|
||||||
RENDER_WIDTH,
|
RENDER_WIDTH,
|
||||||
RENDER_HEIGHT,
|
RENDER_HEIGHT,
|
||||||
|
|
@ -36,6 +37,17 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.player = new Player(level.playerStart);
|
this.player = new Player(level.playerStart);
|
||||||
this.entities = level.entities.map((s) => new Entity(s));
|
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.
|
// Carry health/ammo/score forward from the previous level, if any.
|
||||||
const carry = this.registry.get('carryStats');
|
const carry = this.registry.get('carryStats');
|
||||||
if (carry) {
|
if (carry) {
|
||||||
|
|
@ -101,7 +113,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.game.events.emit('player-empty');
|
this.game.events.emit('player-empty');
|
||||||
return;
|
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);
|
this.game.events.emit('player-fired', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +155,11 @@ export class GameScene extends Phaser.Scene {
|
||||||
|
|
||||||
// --- Render the world. ---
|
// --- Render the world. ---
|
||||||
this.raycaster.render(this.map, this.player.cam);
|
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
|
this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,26 +42,36 @@ export class HudScene extends Phaser.Scene {
|
||||||
.ellipse(cx, GAME_HEIGHT - 110, 40, 30, 0xffd040)
|
.ellipse(cx, GAME_HEIGHT - 110, 40, 30, 0xffd040)
|
||||||
.setAlpha(0);
|
.setAlpha(0);
|
||||||
|
|
||||||
// Event wiring from the game.
|
this.victoryShown = false; // scene instances persist; reset per run
|
||||||
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.
|
// 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.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. */
|
/** Full-screen "you escaped" overlay shown after the final level. */
|
||||||
showVictory(score) {
|
showVictory(score) {
|
||||||
|
if (this.victoryShown) return; // guard against duplicate game-won events
|
||||||
|
this.victoryShown = true;
|
||||||
|
|
||||||
this.add
|
this.add
|
||||||
.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
||||||
.setDepth(10);
|
.setDepth(10);
|
||||||
this.add
|
this.add
|
||||||
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20, 'YOU ESCAPED!', {
|
.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, 'YOU ESCAPED!', {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '48px',
|
fontSize: '48px',
|
||||||
color: '#00ff88',
|
color: '#00ff88',
|
||||||
|
|
@ -69,20 +79,49 @@ export class HudScene extends Phaser.Scene {
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setDepth(11);
|
.setDepth(11);
|
||||||
this.add
|
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',
|
fontFamily: 'monospace',
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
})
|
})
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setDepth(11);
|
.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) {
|
onFired(result) {
|
||||||
// Muzzle flash + a tiny recoil kick on the weapon.
|
// Muzzle flash + a tiny recoil kick on the weapon.
|
||||||
this.muzzle.setAlpha(1);
|
this.muzzle.setAlpha(1);
|
||||||
this.weapon.y += 6;
|
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) {
|
flash(msg) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// natively with no bundler and no JSON-import-attributes support needed.
|
// 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.
|
// Kept in sync with level1.json; used as an offline fallback if fetch fails.
|
||||||
export default {
|
export default {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"name": "Level 1 — Sandbox",
|
"name": "Level 1 — Sandbox",
|
||||||
"width": 16,
|
"width": 16,
|
||||||
"height": 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, 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
|
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 },
|
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "type": "enemy", "x": 6.5, "y": 3.5 },
|
{ "type": "guard", "x": 6.5, "y": 3.5 },
|
||||||
{ "type": "enemy", "x": 12.5, "y": 12.5 },
|
{ "type": "scout", "x": 12.5, "y": 12.5 },
|
||||||
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
||||||
{ "type": "health", "x": 13.5, "y": 5.5 },
|
{ "type": "health", "x": 13.5, "y": 5.5 },
|
||||||
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"name": "Level 1 — Sandbox",
|
"name": "Level 1 — Sandbox",
|
||||||
"width": 16,
|
"width": 16,
|
||||||
"height": 16,
|
"height": 16,
|
||||||
|
|
@ -21,10 +21,20 @@
|
||||||
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
|
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 },
|
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "type": "enemy", "x": 6.5, "y": 3.5 },
|
{ "type": "guard", "x": 6.5, "y": 3.5 },
|
||||||
{ "type": "enemy", "x": 12.5, "y": 12.5 },
|
{ "type": "scout", "x": 12.5, "y": 12.5 },
|
||||||
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
||||||
{ "type": "health", "x": 13.5, "y": 5.5 },
|
{ "type": "health", "x": 13.5, "y": 5.5 },
|
||||||
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"name": "Level 2 — The Gauntlet",
|
"name": "Level 2 — The Gauntlet",
|
||||||
"width": 16,
|
"width": 16,
|
||||||
"height": 16,
|
"height": 16,
|
||||||
|
|
@ -21,12 +21,20 @@
|
||||||
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
|
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 },
|
"playerStart": { "x": 1.5, "y": 14.5, "dir": -1.5707963267948966 },
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "type": "enemy", "x": 4.5, "y": 1.5 },
|
{ "type": "guard", "x": 4.5, "y": 1.5 },
|
||||||
{ "type": "enemy", "x": 12.5, "y": 1.5 },
|
{ "type": "scout", "x": 12.5, "y": 1.5 },
|
||||||
{ "type": "enemy", "x": 7.5, "y": 7.5 },
|
{ "type": "heavy", "x": 7.5, "y": 7.5 },
|
||||||
{ "type": "enemy", "x": 12.5, "y": 11.5 },
|
{ "type": "guard", "x": 12.5, "y": 11.5 },
|
||||||
{ "type": "ammo", "x": 1.5, "y": 1.5 },
|
{ "type": "ammo", "x": 1.5, "y": 1.5 },
|
||||||
{ "type": "ammo", "x": 12.5, "y": 9.5 },
|
{ "type": "ammo", "x": 12.5, "y": 9.5 },
|
||||||
{ "type": "health", "x": 14.5, "y": 14.5 },
|
{ "type": "health", "x": 14.5, "y": 14.5 },
|
||||||
|
|
|
||||||