2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("./types.js").NarrativeSequence} NarrativeSequence
|
|
|
|
|
* @typedef {import("./types.js").NarrativeNode} NarrativeNode
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-22 04:40:48 +00:00
|
|
|
/**
|
|
|
|
|
* NarrativeManager.js
|
|
|
|
|
* Manages the flow of story events, dialogue, and tutorials.
|
|
|
|
|
* Extends EventTarget to broadcast UI updates to the DialogueOverlay.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @class
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
export class NarrativeManager extends EventTarget {
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {NarrativeSequence | null} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.currentSequence = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {NarrativeNode | null} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.currentNode = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Set<string>} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.history = new Set(); // Track IDs of played sequences
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Loads and starts a narrative sequence.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @param {NarrativeSequence} sequenceData - The JSON object of the conversation (from assets/data/narrative/).
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
startSequence(sequenceData) {
|
|
|
|
|
if (!sequenceData || !sequenceData.nodes) {
|
|
|
|
|
console.error("NarrativeManager: Invalid sequence data", sequenceData);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`NarrativeManager: Starting Sequence '${sequenceData.id}'`);
|
|
|
|
|
this.currentSequence = sequenceData;
|
|
|
|
|
this.history.add(sequenceData.id);
|
|
|
|
|
|
|
|
|
|
// Find first node (usually index 0 or has explicit start flag, here we use index 0)
|
|
|
|
|
this.currentNode = sequenceData.nodes[0];
|
|
|
|
|
|
|
|
|
|
// Process entry triggers for the first node
|
|
|
|
|
this._processNodeTriggers(this.currentNode);
|
|
|
|
|
|
|
|
|
|
this.broadcastUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Advances to the next node in the linear sequence.
|
|
|
|
|
* If the current node has choices, this method should strictly be blocked by the UI,
|
|
|
|
|
* but we include a check here for safety.
|
|
|
|
|
*/
|
|
|
|
|
next() {
|
|
|
|
|
if (!this.currentNode) return;
|
|
|
|
|
if (this.currentNode.type === "CHOICE") {
|
|
|
|
|
console.warn(
|
|
|
|
|
"NarrativeManager: Cannot call next() on a CHOICE node. User must select option."
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextId = this.currentNode.next;
|
|
|
|
|
this._advanceToNode(nextId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handles player choice selection from a branching node.
|
|
|
|
|
* @param {number} choiceIndex - The index of the chosen option in the `choices` array.
|
|
|
|
|
*/
|
|
|
|
|
makeChoice(choiceIndex) {
|
|
|
|
|
if (!this.currentNode || !this.currentNode.choices) return;
|
|
|
|
|
|
|
|
|
|
const choice = this.currentNode.choices[choiceIndex];
|
|
|
|
|
if (!choice) return;
|
|
|
|
|
|
|
|
|
|
// Process Choice-specific triggers (e.g., immediate reputation gain)
|
|
|
|
|
if (choice.trigger) {
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
|
new CustomEvent("narrative-trigger", {
|
|
|
|
|
detail: { action: choice.trigger },
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._advanceToNode(choice.next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Internal helper to handle transition logic.
|
|
|
|
|
*/
|
|
|
|
|
_advanceToNode(nextId) {
|
|
|
|
|
if (!nextId || nextId === "END") {
|
|
|
|
|
this.endSequence();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextNode = this.currentSequence.nodes.find((n) => n.id === nextId);
|
|
|
|
|
|
|
|
|
|
if (!nextNode) {
|
|
|
|
|
console.error(
|
|
|
|
|
`NarrativeManager: Node '${nextId}' not found in sequence.`
|
|
|
|
|
);
|
|
|
|
|
this.endSequence();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentNode = nextNode;
|
|
|
|
|
this._processNodeTriggers(this.currentNode);
|
|
|
|
|
|
|
|
|
|
// If it's an ACTION node (invisible), execute trigger and auto-advance
|
|
|
|
|
if (this.currentNode.type === "ACTION") {
|
|
|
|
|
// Triggers are already processed above, just move to next
|
|
|
|
|
// Use setTimeout to allow event loop to breathe if needed, or sync recursion
|
|
|
|
|
this._advanceToNode(this.currentNode.next);
|
|
|
|
|
} else {
|
|
|
|
|
this.broadcastUpdate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_processNodeTriggers(node) {
|
|
|
|
|
if (node && node.trigger) {
|
|
|
|
|
console.log("NarrativeManager: Dispatching Trigger", node.trigger);
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
|
new CustomEvent("narrative-trigger", {
|
|
|
|
|
detail: { action: node.trigger },
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
endSequence() {
|
|
|
|
|
console.log("NarrativeManager: Sequence Ended");
|
|
|
|
|
this.currentSequence = null;
|
|
|
|
|
this.currentNode = null;
|
|
|
|
|
this.dispatchEvent(new CustomEvent("narrative-end"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sends the current node data to the UI (DialogueOverlay).
|
|
|
|
|
*/
|
|
|
|
|
broadcastUpdate() {
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
|
new CustomEvent("narrative-update", {
|
|
|
|
|
detail: {
|
|
|
|
|
node: this.currentNode,
|
|
|
|
|
active: !!this.currentNode,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Export singleton for global access
|
|
|
|
|
export const narrativeManager = new NarrativeManager();
|