Skip to content

Commit 2ed57ce

Browse files
committed
Improve character switching and wanderers
- add game-agnostic switch fallback and position handoff - support spawning multiple opt-in idle wanderers - document wanderer behavior and config options
1 parent de8e0df commit 2ed57ce

5 files changed

Lines changed: 156 additions & 12 deletions

File tree

docs/characters.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ characters.register("hero", {
1717
animationScales: { "hero-walk-front": 0.5 }, // per-animation scale overrides
1818
animationOrigins: { "hero-side": { x: 0.5, y: 1 } },
1919
playable: true, // ← this character is player-controllable
20+
wanderer: true, // ← if inactive, this character wanders the scene
2021
largeBubble: false, // ← thought bubble anchors normally
2122
outfits: {
2223
pajamas: { // alternate render config (see below)
@@ -71,7 +72,20 @@ When more than one playable character is registered, the engine automatically of
7172

7273
1. Stores the new active character id
7374
2. Rebuilds the active walker with the new character's render config
74-
3. Emits a `characterchange` event that NPC reactions can listen for
75+
3. Repositions the newly active character to where the previous active character stood
76+
4. Updates the inactive wandering characters (see below)
77+
5. Emits a `characterchange` event that NPC reactions can listen for
78+
79+
## Wanderers (Inactive Characters)
80+
81+
When multiple playable characters are registered, the currently unselected (inactive) characters can autonomously wander around the scene. The engine manages these characters through `engineScene.idleCharacters`.
82+
83+
To enable wandering, you must opt-in:
84+
- **Registry Opt-in:** Set `wanderer: true` when registering the character. They will automatically wander whenever they are inactive.
85+
- **Explicit Override:** Pass a `wanderers` array to the spawn method in your scene's `create()`: `this.spawnIdleCharacters({ wanderers: ["sister"] })`.
86+
- **Suppressing:** Set `disableIdleCharacter: true` in your `AdventureSceneConfig` to completely disable wanderers for that scene.
87+
88+
You can also provide a `greeting` callback to `spawnIdleCharacters` to give them click-to-speak dialogue lines.
7589

7690
## Outfits (ADR 0006)
7791

src/characters/CharacterRegistry.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* @property {Record<string, number>} [animationScales]
1515
* @property {Record<string, { x?: number, y?: number }>} [animationOrigins]
1616
* @property {boolean} [playable]
17+
* @property {boolean} [wanderer] - whether the character wanders the scene when inactive (opt-in).
1718
* @property {boolean} [largeBubble]
1819
* @property {Record<string, CharacterConfig>} [outfits]
1920
* @property {{ scale?: number, texture?: string, offsetX?: number, offsetY?: number }} [portraitSettings]

src/characters/CharacterSwitcher.js

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { characters } from "./CharacterRegistry.js";
1515
import { store } from "../state/Store.js";
1616
import { UI_DEPTH } from "../ui/UIHelper.js";
1717
import { resolveCharacterPortrait } from "./portraits.js";
18+
import { IdleCharacter } from "../movement/IdleCharacter.js";
1819

1920
// ─── Exported class ─────────────────────────────────────────────────────────
2021

@@ -96,15 +97,102 @@ export class CharacterSwitcher {
9697
/** @param {any} _p @param {any} _x @param {any} _y @param {PointerEvent} event */
9798
(_p, _x, _y, event) => {
9899
event?.stopPropagation?.();
99-
if (this.opts.onSwitch) this.opts.onSwitch();
100-
else {
101-
const s = /** @type {any} */ (this.scene);
102-
if (typeof s.switchActiveCharacter === "function") s.switchActiveCharacter();
103-
}
100+
this.switchActiveCharacter();
104101
},
105102
);
106103
}
107104

105+
/** Switch the active playable character, falling back to game-agnostic logic if the scene doesn't provide it. */
106+
switchActiveCharacter() {
107+
if (this.opts.onSwitch) {
108+
this.opts.onSwitch();
109+
return;
110+
}
111+
112+
const engineScene = /** @type {any} */ (this.scene);
113+
if (typeof engineScene.switchActiveCharacter === "function") {
114+
engineScene.switchActiveCharacter();
115+
return;
116+
}
117+
118+
// Game-agnostic default implementation
119+
const walk = engineScene.walk;
120+
121+
if (!walk) return;
122+
if (walk.locked) return; // don't switch mid-puzzle
123+
124+
const activeName = store.getActiveCharacter() ?? characters.defaultPlayer ?? "";
125+
const playables = characters.playableIds();
126+
if (playables.length < 2) return;
127+
const nextName = playables[(playables.indexOf(activeName) + 1) % playables.length] ?? activeName;
128+
if (nextName === activeName) return;
129+
130+
const activeX = walk.sprite.x;
131+
const activeY = walk.sprite.y;
132+
133+
// Find the IdleCharacter instance for the character that is ABOUT TO BECOME ACTIVE
134+
const nextIdleChar = engineScene.idleCharacters?.find(
135+
/** @param {{ name?: string }} c */
136+
(c) => c.name === nextName,
137+
);
138+
139+
let inactiveX, inactiveY;
140+
const isInactivePresent = nextIdleChar && nextIdleChar.isPresent();
141+
if (isInactivePresent) {
142+
const pos = nextIdleChar.getPosition();
143+
inactiveX = pos ? pos.x : -150;
144+
inactiveY = pos ? pos.y : activeY;
145+
} else {
146+
inactiveX = -150;
147+
inactiveY = activeY;
148+
}
149+
150+
const newActiveX = isInactivePresent ? inactiveX : activeX;
151+
const newActiveY = isInactivePresent ? inactiveY : activeY;
152+
const newInactiveX = activeX;
153+
const newInactiveY = activeY;
154+
155+
// Preserve idle character options (like greetings) to recreate it
156+
const idleOpts = engineScene.idleCharacters?.[0]?.opts ?? {};
157+
158+
store.setActiveCharacter(nextName);
159+
160+
walk.shutdown();
161+
if (walk.sprite) walk.sprite.destroy();
162+
engineScene.walk = null;
163+
164+
for (const c of engineScene.idleCharacters ?? []) c.destroy();
165+
engineScene.idleCharacters = [];
166+
167+
if (typeof engineScene.spawnActiveCharacter === "function") {
168+
engineScene.spawnActiveCharacter({ x: newActiveX, y: newActiveY });
169+
}
170+
171+
if (typeof engineScene.spawnIdleCharacters === "function") {
172+
engineScene.spawnIdleCharacters(idleOpts);
173+
} else if (typeof engineScene.createIdleCharacter === "function") {
174+
// Fallback for games that haven't updated yet
175+
engineScene.idleCharacters = [engineScene.createIdleCharacter()];
176+
} else {
177+
engineScene.idleCharacters = engineScene.sceneConfig?.disableIdleCharacter
178+
? []
179+
: playables.filter((id) => id !== nextName).map((id) =>
180+
new IdleCharacter(engineScene, { ...idleOpts, characterId: id })
181+
);
182+
}
183+
184+
// Make the newly inactive character stand where the active character just was
185+
const newlyInactiveChar = engineScene.idleCharacters?.find(
186+
/** @param {{ name?: string }} c */
187+
(c) => c.name === activeName,
188+
);
189+
if (newlyInactiveChar) {
190+
newlyInactiveChar.presentAt(newInactiveX, newInactiveY);
191+
}
192+
193+
this.createPortrait();
194+
}
195+
108196
/** @param {boolean} visible */
109197
setVisible(visible) {
110198
this.sprite?.setVisible(visible);

src/movement/IdleCharacter.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { walkControllerWanderHost } from "./behaviors/walker.js";
77

88
/**
99
* @typedef {object} IdleCharacterOptions
10+
* @property {string} [characterId] - the specific playable character id to spawn. Defaults to the first inactive.
1011
* @property {(activeId: string, idleId: string) => string} [greeting] - text to
1112
* speak when the idle character is clicked. No bubble if omitted.
1213
* @property {boolean} [startPresent] - spawn already on-screen (vs. the default
@@ -44,13 +45,17 @@ export class IdleCharacter {
4445
if (!sceneConfig?.walkable || sceneConfig.disableIdleCharacter) return;
4546

4647
const playables = characters.playableIds();
48+
/** @type {string} */
4749
this.activeName = store.getActiveCharacter() ?? characters.defaultPlayer ?? "";
4850
// Inert unless the active character is itself playable and a different
4951
// playable exists to stand in as the idle one.
5052
if (!playables.includes(this.activeName)) return;
51-
const idle = playables.find((id) => id !== this.activeName);
52-
if (!idle) return;
53-
this.name = idle;
53+
54+
/** @type {string | undefined} */
55+
const targetId = opts.characterId ?? playables.find((id) => id !== this.activeName);
56+
if (!targetId || targetId === this.activeName) return;
57+
58+
this.name = targetId;
5459
this.config = characters.render(this.name, store.getOutfit(this.name));
5560

5661
this.walkable = sceneConfig.walkable;

src/scene/AdventureScene.js

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { characters } from "../characters/CharacterRegistry.js";
1313
import { engineAssets } from "../assets/EngineAssets.js";
1414
import { loadAssetKeys, registerAssetKeys } from "../assets/assetLoading.js";
1515
import { clearLastTransitionFrom, lastTransitionFrom } from "./transitions.js";
16+
import { IdleCharacter } from "../movement/IdleCharacter.js";
1617

1718
/**
1819
* @typedef {object} SpawnPose
@@ -83,6 +84,8 @@ export class AdventureScene extends Phaser.Scene {
8384
editor;
8485
/** @type {CastDirector} */
8586
cast;
87+
/** @type {IdleCharacter[]} */
88+
idleCharacters = [];
8689
/**
8790
* Count of live NPCs per character id, maintained by `NPC`. Lets wanderers
8891
* skip a character already present in the scene.
@@ -151,14 +154,47 @@ export class AdventureScene extends Phaser.Scene {
151154
loadAssetKeys(this, this.collectAssetKeys());
152155
}
153156

157+
/**
158+
* Spawn the idle characters (the inactive playables) to wander the scene.
159+
* The engine automatically determines which characters are inactive, but only
160+
* spawns those flagged `wanderer: true` in the registry, unless overridden via opts.
161+
* @param {import("../movement/IdleCharacter.js").IdleCharacterOptions & { wanderers?: string[] }} [opts]
162+
*/
163+
spawnIdleCharacters(opts) {
164+
for (const char of this.idleCharacters) char.destroy();
165+
this.idleCharacters = [];
166+
167+
// Scene-wide suppression?
168+
if (this.sceneConfig.disableIdleCharacter) return this.idleCharacters;
169+
170+
const playables = characters.playableIds();
171+
const activeName = this.getActiveCharacterId();
172+
const inactives = playables.filter((id) => id !== activeName);
173+
174+
// Explicit list provided? Otherwise use registry opt-in.
175+
let targetIds = opts?.wanderers;
176+
if (!targetIds) {
177+
targetIds = inactives.filter((id) => characters.get(id)?.wanderer);
178+
}
179+
180+
// Only spawn those that are actually inactive
181+
const toSpawn = targetIds.filter(id => inactives.includes(id));
182+
183+
for (const id of toSpawn) {
184+
this.idleCharacters.push(new IdleCharacter(this, { ...opts, characterId: id }));
185+
}
186+
return this.idleCharacters;
187+
}
188+
154189
/**
155190
* Build the active character's WalkController from the registry, applying
156191
* the character's current outfit and any per-scene scale override. Reused
157192
* by `create()` and by a subclass's switch/rebuild.
158-
* @param {string} name @param {{ x: number, y: number }} startPos
193+
* @param {{ x: number, y: number }} startPos
159194
* @param {"up" | "down" | "left" | "right"} [initialFacing]
160195
*/
161-
spawnActiveCharacter(name, startPos, initialFacing) {
196+
spawnActiveCharacter(startPos, initialFacing) {
197+
const name = this.getActiveCharacterId();
162198
const cfg = this.sceneConfig;
163199
const conf = characters.render(name, store.getOutfit(name));
164200
const baseScale = 0.55;
@@ -230,7 +266,7 @@ export class AdventureScene extends Phaser.Scene {
230266
}
231267
if (lastTransitionFrom) clearLastTransitionFrom();
232268

233-
this.spawnActiveCharacter(this.getActiveCharacterId(), startPos, initialFacing);
269+
this.spawnActiveCharacter(startPos, initialFacing);
234270
if (cfg.suppressActiveCharacter) this.suppressActiveCharacter();
235271

236272
this.inventory = new InventoryLayer(this, {

0 commit comments

Comments
 (0)