feat(wolfenstein): guard sprite animation, death sequence, and E-key door interact
- Add directional billboarding for guards: front/side/back idle & walk frames selected by relative angle to camera, with flipX for left side - Add shooting pose (always shown when guard is in attack state) - Add 2-frame walk cycle (300ms) for moving guards (chase/alert/patrol) - Add death sequence: fall (350ms) → ground (2s) → fade (800ms), view-only state in WolfensteinView, pooled sprite destroyed on completion - Wire guard sheet texture from artwork JSON (enemies.png, 9×7 grid, frames 0-8 used) - Add E key as alternate interact key for opening doors - Update door hint text to show [SPACE/E] - Update sprites.md with full animation/facing/death documentation
This commit is contained in:
parent
f6081de0a8
commit
965e4308c9
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
|
|
@ -2,7 +2,7 @@
|
||||||
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
|
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
|
||||||
"sheets": {
|
"sheets": {
|
||||||
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 },
|
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 },
|
||||||
"guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 },
|
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 },
|
||||||
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 }
|
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 }
|
||||||
},
|
},
|
||||||
"artwork": [
|
"artwork": [
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
||||||
this._mouseFireHeld = false;
|
this._mouseFireHeld = false;
|
||||||
this._lastAutosave = 0;
|
this._lastAutosave = 0;
|
||||||
|
|
||||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC,SPACE');
|
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE');
|
||||||
|
|
||||||
this._bindPointerLock();
|
this._bindPointerLock();
|
||||||
|
|
||||||
|
|
@ -220,7 +220,9 @@ export default class WolfensteinGame extends Phaser.Scene {
|
||||||
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
||||||
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
||||||
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
||||||
if (Phaser.Input.Keyboard.JustDown(k.SPACE)) Logic.openNearestDoor(this.state);
|
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
|
||||||
|
Logic.openNearestDoor(this.state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- loop
|
// ------------------------------------------------------------- loop
|
||||||
|
|
@ -298,8 +300,8 @@ export default class WolfensteinGame extends Phaser.Scene {
|
||||||
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
|
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
|
||||||
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
|
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
|
||||||
// Doors no longer open automatically on approach (see openNearestDoor) —
|
// Doors no longer open automatically on approach (see openNearestDoor) —
|
||||||
// without this, there's no way to discover that Space is the interact key.
|
// without this, there's no way to discover that Space/E is the interact key.
|
||||||
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
|
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
|
||||||
return objs;
|
return objs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,23 @@ export const NUM_COLUMNS = 480;
|
||||||
// generic per-column wall-texture path below at all.
|
// generic per-column wall-texture path below at all.
|
||||||
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
|
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
|
||||||
|
|
||||||
|
// Frame indices in the `wolfenstein-guard-sheet` sheet (128x128 cells,
|
||||||
|
// row-major — see sprites.md). Only the sheet's first row (frames 0-8) is
|
||||||
|
// populated; further columns/rows sit unused until more enemy types land.
|
||||||
|
const GUARD_FRAME = {
|
||||||
|
shoot: 0, frontIdle: 1, frontWalk: 2, sideIdle: 3, sideWalk: 4, backIdle: 5, backWalk: 6,
|
||||||
|
fall: 7, ground: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms.
|
||||||
|
const WALK_CYCLE_MS = 300;
|
||||||
|
|
||||||
|
// Death sequence timing, ms: fall pose -> hold on the ground -> fade out.
|
||||||
|
// Angle-independent (no directional fall/ground art), same as the shoot pose.
|
||||||
|
const DEATH_FALL_MS = 350;
|
||||||
|
const DEATH_GROUND_MS = 2000;
|
||||||
|
const DEATH_FADE_MS = 800;
|
||||||
|
|
||||||
export default class WolfensteinView {
|
export default class WolfensteinView {
|
||||||
constructor(scene, rules) {
|
constructor(scene, rules) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
|
@ -49,11 +66,17 @@ export default class WolfensteinView {
|
||||||
this.colWidth = VIEW_W / NUM_COLUMNS;
|
this.colWidth = VIEW_W / NUM_COLUMNS;
|
||||||
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
|
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
|
||||||
this.spritePool = new Map();
|
this.spritePool = new Map();
|
||||||
|
// enemy id -> { startMs } for the falling/on-ground/fade-out sequence
|
||||||
|
// (view-only, transient — WolfensteinLogic's enemies just have `dead`,
|
||||||
|
// no death-animation phase, since that's not simulation state).
|
||||||
|
this.deathAnim = new Map();
|
||||||
|
|
||||||
this.wallTexture = scene.textures.exists('wolfenstein-walls')
|
this.wallTexture = scene.textures.exists('wolfenstein-walls')
|
||||||
? scene.textures.get('wolfenstein-walls') : null;
|
? scene.textures.get('wolfenstein-walls') : null;
|
||||||
this.doorTexture = scene.textures.exists('wolfenstein-doors')
|
this.doorTexture = scene.textures.exists('wolfenstein-doors')
|
||||||
? scene.textures.get('wolfenstein-doors') : null;
|
? scene.textures.get('wolfenstein-doors') : null;
|
||||||
|
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
|
||||||
|
? scene.textures.get('wolfenstein-guard-sheet') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(state, camera) {
|
render(state, camera) {
|
||||||
|
|
@ -156,9 +179,18 @@ export default class WolfensteinView {
|
||||||
_drawSprites(state, camera) {
|
_drawSprites(state, camera) {
|
||||||
const live = new Set();
|
const live = new Set();
|
||||||
const sprites = [];
|
const sprites = [];
|
||||||
|
const now = this.scene.time.now;
|
||||||
for (const e of state.enemies) {
|
for (const e of state.enemies) {
|
||||||
if (e.dead) continue;
|
if (e.dead) {
|
||||||
sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 });
|
const dead = this._deadGuardSprite(e, now);
|
||||||
|
if (dead) sprites.push(dead);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const facing = this._guardFacing(e, camera, now);
|
||||||
|
sprites.push({
|
||||||
|
key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9,
|
||||||
|
guardFrame: facing.frame, guardFlip: facing.flip,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const pk of state.pickups) {
|
for (const pk of state.pickups) {
|
||||||
if (pk.taken) continue;
|
if (pk.taken) continue;
|
||||||
|
|
@ -186,15 +218,90 @@ export default class WolfensteinView {
|
||||||
}
|
}
|
||||||
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
|
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
|
||||||
const img = this._ensureSprite(s.key, s.tex);
|
const img = this._ensureSprite(s.key, s.tex);
|
||||||
img.setTexture(s.tex);
|
if (s.guardFrame != null && this.guardTexture) {
|
||||||
|
img.setTexture('wolfenstein-guard-sheet', s.guardFrame);
|
||||||
|
img.setFlipX(s.guardFlip);
|
||||||
|
} else {
|
||||||
|
img.setTexture(s.tex);
|
||||||
|
img.setFlipX(false);
|
||||||
|
}
|
||||||
img.setPosition(screenColF * this.colWidth, VIEW_H / 2);
|
img.setPosition(screenColF * this.colWidth, VIEW_H / 2);
|
||||||
img.setDisplaySize(spriteSize, spriteSize);
|
img.setDisplaySize(spriteSize, spriteSize);
|
||||||
img.setDepth(10 + Math.max(0, 100 - s._depth));
|
img.setDepth(10 + Math.max(0, 100 - s._depth));
|
||||||
|
img.setAlpha(s.alpha ?? 1);
|
||||||
img.setVisible(true);
|
img.setVisible(true);
|
||||||
}
|
}
|
||||||
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
|
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Falling -> on-the-ground -> fade-out sequence for a just-killed guard,
|
||||||
|
* keyed by enemy id in `this.deathAnim` (first call after `e.dead` flips
|
||||||
|
* true starts the clock). Angle-independent, like the shoot pose — frames
|
||||||
|
* 7/8 have no directional variants. Returns null once the sequence has
|
||||||
|
* fully faded, after tearing down the pooled sprite so it doesn't linger.
|
||||||
|
* The `deathAnim` entry itself is kept forever (not deleted) once finished
|
||||||
|
* — `e.dead` stays true for the rest of the level, so a deleted entry
|
||||||
|
* would just read as "never started" on the next render call and restart
|
||||||
|
* the whole fall/ground/fade sequence from scratch, looping it forever.
|
||||||
|
*/
|
||||||
|
_deadGuardSprite(e, now) {
|
||||||
|
let anim = this.deathAnim.get(e.id);
|
||||||
|
if (!anim) { anim = { startMs: now }; this.deathAnim.set(e.id, anim); }
|
||||||
|
const elapsed = now - anim.startMs;
|
||||||
|
const total = DEATH_FALL_MS + DEATH_GROUND_MS + DEATH_FADE_MS;
|
||||||
|
if (elapsed >= total) {
|
||||||
|
this._destroySprite(`enemy:${e.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = GUARD_FRAME.fall;
|
||||||
|
let alpha = 1;
|
||||||
|
if (elapsed >= DEATH_FALL_MS) {
|
||||||
|
frame = GUARD_FRAME.ground;
|
||||||
|
const fadeElapsed = elapsed - DEATH_FALL_MS - DEATH_GROUND_MS;
|
||||||
|
if (fadeElapsed > 0) alpha = Math.max(0, 1 - fadeElapsed / DEATH_FADE_MS);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9,
|
||||||
|
guardFrame: frame, guardFlip: false, alpha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the guard sheet's frames to show for enemy `e`, given where
|
||||||
|
* the camera is standing relative to it. `e.angle` is the guard's own
|
||||||
|
* heading (set by stepEnemyAI/stepPatrol in WolfensteinLogic) — comparing
|
||||||
|
* it against the bearing from the guard to the camera buckets the view
|
||||||
|
* into front/back/side, the same billboard-selection idea classic
|
||||||
|
* sprite-based FPSes use. Only one shooting pose exists, so an attacking
|
||||||
|
* guard always shows it head-on regardless of viewing angle (attacking
|
||||||
|
* guards don't move, so the player is almost always roughly in front of
|
||||||
|
* them anyway). `flip` mirrors the single "facing right" pose for the
|
||||||
|
* left side — if left/right ever reads backwards in-game, flip the sign
|
||||||
|
* on the `rel > 0` line below, nothing else needs to change.
|
||||||
|
*/
|
||||||
|
_guardFacing(e, camera, now) {
|
||||||
|
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
|
||||||
|
|
||||||
|
const isMoving = e.state === 'chase' || e.state === 'alert'
|
||||||
|
|| (e.state === 'idle' && e.patrol && e.patrol.length > 0);
|
||||||
|
const walkOn = isMoving && Math.floor(now / WALK_CYCLE_MS) % 2 === 0;
|
||||||
|
|
||||||
|
const toCam = Math.atan2(camera.y - e.y, camera.x - e.x);
|
||||||
|
let rel = toCam - e.angle;
|
||||||
|
rel = Math.atan2(Math.sin(rel), Math.cos(rel));
|
||||||
|
const absRel = Math.abs(rel);
|
||||||
|
|
||||||
|
if (absRel < Math.PI / 4) {
|
||||||
|
return { frame: walkOn ? GUARD_FRAME.frontWalk : GUARD_FRAME.frontIdle, flip: false };
|
||||||
|
}
|
||||||
|
if (absRel > (3 * Math.PI) / 4) {
|
||||||
|
return { frame: walkOn ? GUARD_FRAME.backWalk : GUARD_FRAME.backIdle, flip: false };
|
||||||
|
}
|
||||||
|
return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
_ensureSprite(key, tex) {
|
_ensureSprite(key, tex) {
|
||||||
let img = this.spritePool.get(key);
|
let img = this.spritePool.get(key);
|
||||||
if (!img) {
|
if (!img) {
|
||||||
|
|
@ -209,6 +316,11 @@ export default class WolfensteinView {
|
||||||
if (img) img.setVisible(false);
|
if (img) img.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_destroySprite(key) {
|
||||||
|
const img = this.spritePool.get(key);
|
||||||
|
if (img) { img.destroy(); this.spritePool.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
for (const img of this.spritePool.values()) img.destroy();
|
for (const img of this.spritePool.values()) img.destroy();
|
||||||
this.spritePool.clear();
|
this.spritePool.clear();
|
||||||
|
|
|
||||||
|
|
@ -99,23 +99,64 @@ entered — no per-frame lookup cost.
|
||||||
|
|
||||||
## Guard enemy — `sheets.guard`
|
## Guard enemy — `sheets.guard`
|
||||||
|
|
||||||
Per the artwork JSON: `frameWidth: 128, frameHeight: 128`. **Minimum useful
|
**Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON:
|
||||||
size: a single 128×128 PNG (1 frame)** — that's all the current renderer can
|
`frameWidth: 128, frameHeight: 128`, painted sheet is
|
||||||
show. `WolfensteinView._drawSprites` calls `img.setTexture('wolf-guard')`
|
`assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a
|
||||||
with no frame index, so Phaser always displays frame 0 (top-left) of
|
plain row-major Phaser spritesheet — only the first row, frames 0-8, is
|
||||||
whatever sheet is loaded under that key; there is no facing/animation frame
|
used; the remaining rows sit unused until a second enemy type lands there).
|
||||||
selection logic anywhere in the code yet. Painting extra frames into the
|
Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1`
|
||||||
sheet is harmless (they'll just sit unused) but won't animate or turn to
|
facing forward (idle), `2` facing forward walking, `3` facing right (idle,
|
||||||
face the player until that selection logic is written.
|
profile), `4` facing right walking, `5` facing away (idle), `6` facing away
|
||||||
|
walking, `7` falling (death), `8` on the ground (death). There is no "facing
|
||||||
|
left" pose — `WolfensteinView` shows the "facing right" frames with
|
||||||
|
`img.setFlipX(true)` for that case instead, per the sheet's own convention.
|
||||||
|
Frames 7/8 are likewise angle-independent — no directional death poses.
|
||||||
|
|
||||||
If/when directional billboarding is worth building, the natural layout to
|
`WolfensteinView._guardFacing(e, camera, now)` picks the frame every render:
|
||||||
target is a grid, frame index = `row * cols + col` in Phaser's row-major
|
- `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped,
|
||||||
order: **8 rows (one per 45° facing angle, starting from "facing camera" and
|
regardless of viewing angle — there's only one shooting pose, and
|
||||||
going clockwise) × N columns** for whatever animation set is wanted per
|
attacking guards stand still and roughly face the player anyway.
|
||||||
angle, e.g. `{idle, walk1, walk2, shoot, die1, die2}` (6 cols → a 768×1024
|
- Otherwise it compares the guard's own heading (`e.angle`, set by
|
||||||
sheet). That's a proposal, not a spec anything reads — pick a smaller set
|
`stepEnemyAI`/`stepPatrol` in `WolfensteinLogic.js`) against the bearing
|
||||||
(e.g. idle + walk + shoot only, 3 cols) if painting 48 frames is too much
|
from the guard to the camera, bucketing the relative angle into
|
||||||
up front, since nothing currently depends on the exact column count.
|
front/back/side (±45°/±135° thresholds) the way classic sprite-based FPS
|
||||||
|
billboarding does — front when the guard is looking at the camera, back
|
||||||
|
when its back is turned, side otherwise (mirrored via `flipX` depending on
|
||||||
|
which side). If left/right ever reads backwards in-game, it's a one-line
|
||||||
|
sign flip in that method, not a rethink.
|
||||||
|
- Walking-vs-idle pose alternates on a shared `WALK_CYCLE_MS` (300ms) timer
|
||||||
|
whenever the guard is actually moving — `state === 'chase'`, `'alert'`
|
||||||
|
(which also moves per `stepEnemyAI`), or `'idle'` while following a
|
||||||
|
patrol route with waypoints. An idle guard with no patrol just stands
|
||||||
|
(frame 1/3/5, whichever facing applies), never animating.
|
||||||
|
|
||||||
|
`_guardFacing` only runs for live guards (`!e.dead`). Once `e.dead` flips
|
||||||
|
true, `WolfensteinView._deadGuardSprite(e, now)` takes over instead and
|
||||||
|
drives a **view-only** death sequence — `WolfensteinLogic.js`'s enemies just
|
||||||
|
have a `dead` boolean, no animation-phase field, since that's presentation
|
||||||
|
state, not simulation state (it doesn't round-trip through save/load and
|
||||||
|
isn't checked by `tools/verifyWolfenstein.js`). Keyed by enemy id in
|
||||||
|
`this.deathAnim` (a `Map`, separate from the position/depth sprite pool),
|
||||||
|
the first render tick after death records a `startMs` and the elapsed time
|
||||||
|
since then drives three phases: `DEATH_FALL_MS` (350ms) showing frame 7
|
||||||
|
(falling), then `DEATH_GROUND_MS` (2000ms) showing frame 8 fully opaque
|
||||||
|
(on the ground), then `DEATH_FADE_MS` (800ms) still on frame 8 while alpha
|
||||||
|
linearly ramps `1 -> 0`. Once the total elapsed time clears all three
|
||||||
|
phases, the method destroys the pooled `Image` outright (not just hides
|
||||||
|
it — a dead guard never comes back, unlike a live one flickering in/out of
|
||||||
|
the depth buffer) so the body's sprite doesn't linger, and stops returning a
|
||||||
|
sprite for it at all (excluded from that frame's list, so it's no longer
|
||||||
|
drawn, occlusion-tested, or depth-sorted). The `deathAnim` entry itself is
|
||||||
|
kept forever rather than deleted — `e.dead` stays true for the rest of the
|
||||||
|
level, so deleting it would make the next render call read as "just died"
|
||||||
|
again and restart the fall/ground/fade sequence from scratch on a loop.
|
||||||
|
|
||||||
|
Falls back to the old flat placeholder (`WolfensteinArt.js`'s single-frame
|
||||||
|
procedural `wolf-guard` texture, always frame 0, never flipped) if the sheet
|
||||||
|
isn't loaded, same convention as the wall/door texture fallbacks — this
|
||||||
|
applies to the death sequence too (dead guards would just render as the
|
||||||
|
one flat placeholder frame, never flipped, for the same three phases/timing,
|
||||||
|
alpha fade included, until removed).
|
||||||
|
|
||||||
## Standalone images — `artwork[]`
|
## Standalone images — `artwork[]`
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue