Files
noname-shuying/mengsan/runtime/dialogue.js
T
2026-09-28 20:58:29 +08:00

193 lines
7.4 KiB
JavaScript

import { mountMenu } from "./navigation.js";
import { ui, get } from "../../../../noname.js";
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "./dialogue-choice.js";
const TYPE_NAMES = {
character: "角色",
narrator: "旁白",
sound: "音效",
choice: "选择发言",
};
const resolveCharacter = (character, run) => character == "$player" ? run?.player?.character : character;
const resolveSpeaker = (line, character) => {
if (line.speaker == "$player") return character ? get.translation(character) : "你";
if (line.speaker) return line.speaker;
if (line.type == "character" && character) return get.translation(character);
return TYPE_NAMES[line.type] || "旁白";
};
export const splitDialogueText = text => Array.from(String(text || ""));
export const playDialogue = (lines, options = {}) => new Promise(resolve => {
if (!Array.isArray(lines) || !lines.length) {
resolve();
return;
}
const overlay = ui.create.div(".mengsan-dialogue-overlay-shuying", ui.window);
const stage = ui.create.div(".mengsan-dialogue-stage-shuying", overlay);
ui.create.div(".mengsan-dialogue-chapter-shuying", options.title || "剧情", stage);
const portraitFrame = ui.create.div(".mengsan-dialogue-portrait-frame-shuying", stage);
const textBox = ui.create.div(".mengsan-dialogue-box-shuying", stage);
const speaker = ui.create.div(".mengsan-dialogue-speaker-shuying", textBox);
const text = ui.create.div(".mengsan-dialogue-text-shuying", textBox);
const progress = ui.create.div(".mengsan-dialogue-progress-shuying", textBox);
const indicator = ui.create.div(".mengsan-dialogue-indicator-shuying", "◆", textBox);
const choices = ui.create.div(".mengsan-dialogue-choices-shuying", stage);
const skipButton = document.createElement("button");
skipButton.type = "button";
skipButton.className = "mengsan-dialogue-skip-shuying";
skipButton.textContent = "跳过对话";
skipButton.setAttribute("aria-label", "跳过对话,未选择的发言使用默认选项");
stage.appendChild(skipButton);
let lineIndex = -1;
let timer = null;
let typing = false;
let paused = false;
let currentSpeed = 28;
let characters = [];
let characterIndex = 0;
let currentLine = null;
let awaitingChoice = false;
const portrait = ui.create.div(".mengsan-dialogue-portrait-shuying", portraitFrame);
const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
const stopTimer = () => {
if (timer != null) clearTimeout(timer);
timer = null;
};
const finishTyping = () => {
stopTimer();
text.textContent = characters.join("");
characterIndex = characters.length;
typing = false;
if (currentLine?.type === "choice" && awaitingChoice) showChoices(currentLine);
else indicator.classList.add("visible");
};
function showChoices(line) {
choices.replaceChildren();
for (const item of line.choices) {
const button = document.createElement("button");
button.type = "button";
button.className = "mengsan-dialogue-choice-shuying";
button.textContent = item.text;
button.addEventListener("click", event => {
event.stopPropagation();
if (!awaitingChoice) return;
text.textContent = applyDialogueChoice(options.run, line, item.id);
awaitingChoice = false;
choices.replaceChildren();
speaker.textContent = get.translation(options.run.player.character);
stage.dataset.lineType = "character";
const character = options.run.player.character;
portraitFrame.classList.add("visible");
portrait.setBackground(character, "character");
indicator.classList.add("visible");
textBox.focus();
});
choices.appendChild(button);
}
choices.querySelector("button")?.focus();
}
const scheduleCharacter = speed => {
if (!typing || paused) return;
text.textContent += characters[characterIndex++];
if (characterIndex >= characters.length) {
finishTyping();
return;
}
timer = setTimeout(() => scheduleCharacter(speed), speed);
};
const close = () => {
stopTimer();
skipButton.removeEventListener("click", handleSkip);
textBox.removeEventListener("click", handleClick);
textBox.removeEventListener("keydown", handleKeydown);
choices.replaceChildren();
overlay.remove();
resolve();
};
const showNextLine = () => {
do { lineIndex++; } while (lineIndex < lines.length && !isDialogueLineVisible(lines[lineIndex], options.run));
if (lineIndex >= lines.length) {
close();
return;
}
const line = lines[lineIndex];
currentLine = line;
awaitingChoice = line.type === "choice";
choices.replaceChildren();
const lineType = TYPE_NAMES[line.type] ? line.type : "narrator";
const character = resolveCharacter(line.character, options.run);
stage.dataset.lineType = lineType;
speaker.textContent = resolveSpeaker(line, character);
progress.textContent = `${lineIndex + 1} / ${lines.length}`;
indicator.classList.remove("visible");
if (lineType == "character" && character) {
portraitFrame.classList.add("visible");
portrait.setBackground(character, "character");
portrait.setAttribute("aria-label", `${speaker.textContent}立绘`);
}
else {
portraitFrame.classList.remove("visible");
portrait.removeAttribute("aria-label");
}
characters = splitDialogueText(line.text);
characterIndex = 0;
text.textContent = "";
typing = characters.length > 0;
const speed = Number.isFinite(line.speed) ? Math.max(0, line.speed) : 28;
currentSpeed = speed;
if (!typing || reduceMotion || speed == 0) finishTyping();
else scheduleCharacter(speed);
};
function handleClick(event) {
event.preventDefault();
event.stopPropagation();
if (paused) return;
if (typing) finishTyping();
else if (awaitingChoice) return;
else showNextLine();
}
function handleKeydown(event) {
if (event.key == "Enter" || event.key == " ") handleClick(event);
}
function handleSkip(event) {
event.preventDefault();
event.stopPropagation();
if (paused) return;
applySkippedDialogueChoices(lines, lineIndex + (awaitingChoice ? 0 : 1), options.run);
close();
}
textBox.setAttribute("role", "button");
textBox.setAttribute("tabindex", "0");
textBox.setAttribute("aria-label", "剧情文本:点击补全文字或继续");
textBox.addEventListener("click", handleClick);
textBox.addEventListener("keydown", handleKeydown);
skipButton.addEventListener("click", handleSkip);
mountMenu(overlay, {
onOpen() { paused = true; stopTimer(); },
onClose() { paused = false; if (typing) scheduleCharacter(currentSpeed); },
});
const help = ui.create.div(".mengsan-dialogue-help-shuying", stage);
help.textContent = "点击补全文字 · 再次点击继续 · Enter / 空格";
showNextLine();
textBox.focus();
});