I’m developing an RPG with many NPCs. I will start by saying I haven’t done exactly what you’re asking, but I’ll describe what I’ve done. For my game, having the exact architecture you describe with total separation of concerns didn’t occur to me. My architecture is maybe halfway like that.
I have an NPC class, with this.sprite and this.scene parameters, which allow the NPC instance to use the sprite and update the scene directly. Obviously, all the NPC data gets passed to this class. I have these classes:
Overworld
Structure
NPCWorld
My game uses the real clock, like Animal Crossing, so NPCs do different things depending on the time of day. I have an “npcs” object with their static data, including their schedules:
Evelyn: {
speed: 20,
portrait:6,
quest:true,
ui:false,
petHome: '42_100_basement',
petPosition: {x: 130, y: 100},
schedule: [
{
begin: '00:00',
end: '08:00',
location: '42_100_basement',
type: 'Structure',
coords: { x: 171, y: 127 },
direction: 'down',
name: 'sleep',
idle: null,
home: null
},
{
begin: '08:01',
end: '23:59',
location: 'library',
type: 'Structure',
coords: '11_5',
direction: 'down',
name: 'work',
idle: null,
home: null
}
]
}
Depending on where the hero is (Structure vs. Overworld) NPCWorld runs in parallel and stores the locations of all the spawned NPCs. When the player enters or exits a structure, NPCWorld reads their current location, their schedule, and then updates them. Like one thing I do is let’s say an NPC is walking around the overworld, when I enter the shop, it stores their overworld location and then spawns them on the same tile when I exit the shop. I do not simulate their movement, but this way, they don’t have to constantly spawn at their original location. That’s all handled in the parallel scene. That’s one of the main reasons I created it. I wanted something that was always on, managing the NPC simulation.
Further, how does the game state class find the sprite class?
For me, I pass the sprite and the scene to my NPC class and update them there.
Here is pseudo code for my NPCWorld:
class NPCWorld extends Scene {
constructor() {
// Retained between scene starts.
this.savedOutdoorStates = {};
}
create() {
this.targetScene = getActiveMapScene();
this.npcs = {};
this.spawnQueue = [];
definitions = combine(
regularNPCDefinitions,
generatedVisitorDefinitions
);
forEach(definitions, (id, data) => {
npc = new NPC(id, data, this.targetScene);
location = npc.getCurrentScheduledLocation();
saved = this.savedOutdoorStates[id];
if(!location) {
delete this.savedOutdoorStates[id];
return;
}
if(saved && !matchesSchedule(saved, location)) {
delete this.savedOutdoorStates[id];
saved = null;
}
if(location.type === "Structure") {
if(location.building === this.targetScene.building) {
npc.spawn();
this.npcs[id] = npc;
}
return;
}
if(saved && npc.shouldSpawn) {
restorePositionDestinationAndFacing(npc, saved);
this.npcs[id] = npc;
return;
}
if(npc.shouldSpawn && distanceFromPlayer(location) > 256) {
npc.spawn();
this.npcs[id] = npc;
} else {
this.spawnQueue.push({ id, npc });
}
});
}
// Called before leaving the Overworld.
saveOverworldPositions() {
forEach(this.npcs, (id, npc) => {
if(!npc.sprite.active || npc.location.type !== "Overworld") {
return;
}
this.savedOutdoorStates[id] = {
schedule: identifySchedule(npc.location),
position: npc.sprite.position,
direction: npc.sprite.direction,
facing: getFacing(npc.sprite),
standing: npc.sprite.standing,
destination: npc.destination,
activity: npc.location.name
};
});
}
update() {
forEach(this.spawnQueue, (entry) => {
location = entry.npc.getCurrentScheduledLocation();
if(entry.npc.shouldSpawn && distanceFromPlayer(location) > 256) {
entry.npc.spawn();
this.npcs[entry.id] = entry.npc;
removeFromQueue(entry);
}
});
forEach(this.npcs, (id, npc) => {
if(npc.sprite.active && npc.location.type === "Overworld") {
npc.sprite.setDepth(depthFromY(npc.sprite.y));
}
});
}
}
I hope that helps!