Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+176
View File
@@ -0,0 +1,176 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path';
import { Story } from 'inkjs';
import {
ChoiceResult,
StoryTag,
TurnResult,
} from '../interfaces/turn-result';
import { getTagValue, parseTags } from '../utils/tag-parser';
const { Compiler } = require('inkjs/full') as { Compiler: new (
inkSource: string,
options?: {
sourceFilename?: string;
errorHandler?: (message: string, type: number) => void;
fileHandler?: {
ResolveInkFilename: (filename: string) => string;
LoadInkFileContents: (filename: string) => string;
};
},
) => { Compile: () => { ToJson: () => string } | null } };
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult {
const resolvedSource = path.resolve(sourcePath);
const resolvedOutput = path.resolve(outputPath);
if (!existsSync(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings: string[] = [];
const errors: string[] = [];
const source = readFileSync(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename: string) =>
path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename),
LoadInkFileContents: (filename: string) =>
readFileSync(path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message: string, type: number) => {
if (type === 1) {
warnings.push(message);
} else {
errors.push(message);
}
},
});
const story = compiler.Compile();
if (!story || errors.length > 0) {
throw new Error(`Ink compilation failed:\n${errors.join('\n')}`);
}
if (warnings.length > 0) {
warnings.forEach((warning) => console.warn(`[ink] ${warning}`));
}
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
writeFileSync(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
export class InkEngine {
private story: Story | null = null;
private nextTurnId = 1;
constructor(private readonly storyPath: string) {}
isRunning(): boolean {
if (!this.story) return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame(): TurnResult {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex: number): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const choice = this.story.currentChoices.find((item) => item.index === choiceIndex);
if (!choice) {
throw new Error(`Ink choice ${choiceIndex} is not available`);
}
this.story.ChooseChoiceIndex(choice.index);
return this.continueStory();
}
saveGame(): string {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
}
loadGame(savedState: string): TurnResult {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
return this.continueStory();
}
private loadStory(): Story {
const resolvedPath = path.resolve(this.storyPath);
if (!existsSync(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
const storyJson = JSON.parse(readFileSync(resolvedPath, 'utf8'));
return new Story(storyJson);
}
private continueStory(): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs: TurnResult['paragraphs'] = [];
const globalTags: StoryTag[] = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = parseTags(this.story.currentTags || []);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
if (text) {
paragraphs.push({ text, tags });
} else {
tags.forEach((tag) => globalTags.push(tag));
}
}
const choices = this.story.currentChoices.map((choice): ChoiceResult => {
const tags = parseTags(choice.tags || []);
const category = getTagValue(tags, 'action');
const letter = getTagValue(tags, 'letter');
return {
index: choice.index,
text: String(choice.text || '').trim(),
tags,
category,
letter,
};
});
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
globalTags: globalTags.length > 0 ? globalTags : undefined,
};
}
}