Files
datingsim-visual-editor/lib/vn-parser.ts

291 lines
9.1 KiB
TypeScript

// --- CONSTANTS ---
export const CHARACTER_COLORS = [
'#ff4d4d', '#4d79ff', '#4dff4d', '#ffff4d', '#ff4dff', '#4dffff',
'#ffa64d', '#a64dff', '#ff8080', '#80ff80', '#8080ff'
];
export const getCharColor = (name: string | null) => {
if (!name) return '#cccccc';
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return CHARACTER_COLORS[Math.abs(hash) % CHARACTER_COLORS.length];
};
export const DEFAULT_SCRIPT = `// Define Assets
define_sprite Agnieszka wave "https://placehold.co/400x600/ff4d4d/ffffff?text=Agnieszka+Wave"
define_sprite Player shock "https://placehold.co/400x600/4d79ff/ffffff?text=Player+Shock"
// Scene 1: The Rooftop Meeting
sprite Agnieszka happy at pos2
sprite Player normal at pos7
say Agnieszka "To jest najprostszy tekst w tekstboxie!"
say Agnieszka "Chcesz zobaczyć specjalny efekt?"
choice "Tak, pokaż mi!" jump:special_effect
choice "Nie, przejdźmy dalej" jump:continue_scene
label special_effect
sprite Agnieszka wave at pos3
say Agnieszka "Oto on! Pogrubiony tekst: " b"BUM!"
say Agnieszka "I teraz wracamy do głównej sceny."
label continue_scene
say Agnieszka "Kontynuujemy naszą rozmowę."
say Agnieszka "To jest najprostszy tekst w tekstboxie!" "Tu jest dużo tekstu."
say "To by było trochę niekomfortowe trzymać w jednej linii." "Ale wszyscy wiedzą."
say "Że to nadal Agnieszka."
say "To jest tekst bez osoby podanej!"
sprite Agnieszka wave at pos3
say Agnieszka b"To jest pogrubione" "A to nie!" b"P""ierwsza litera tylko"
say Agnieszka i"Hello! This is italicized" "And this isn't"
say Agnieszka u"This is underlined" "And this isn't."
say Agnieszka s"This is crossed out" "And this isn't."
say Agnieszka "This line is instant" w"This line shows up after 1s"
say Agnieszka "This line is instant" w"This line shows up after 3s"(w:3s)
say Agnieszka "This line is default colored" c"This line isn't"(c:green)
say Agnieszka "This line is default font" f"This line isn't"(f:TimesNewRoman)
wait 5s skippable
sprite Agnieszka happy at pos5
set AGNIESZKA_MET true
fire ROOFTOP_SCENE_START
say Agnieszka "This is instant" click "Gotta click to read this one"
say Agnieszka "This line is instant" clicknoskip "Can't even autoclick this one."
nextfile scena_koncowa`;
// --- TYPES ---
export interface Modifier {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
wait: boolean;
waitDuration: string | null;
color: string | null;
font: string | null;
speed: string | null;
clickLock: string | null;
click: boolean;
clicknoskip: boolean;
inlineBranch: any[];
}
export interface Segment {
text: string;
modifiers: Modifier;
}
export interface ChoiceOption {
text: string;
jumpLabel: string;
}
export interface Statement {
type: 'say' | 'wait' | 'sprite' | 'set' | 'fire' | 'nextfile' | 'comment' | 'choice' | 'label';
character?: string | null;
segments?: Segment[];
continuations?: Segment[];
duration?: string;
skippable?: boolean;
emotion?: string;
variable?: string;
value?: string;
event?: string;
args?: string[];
filename?: string;
text?: string;
lineNumber: number;
label?: string;
choices?: ChoiceOption[];
}
// --- PARSER ---
export function parseScript(text: string): Statement[] {
const lines = text.split('\n');
const statements: Statement[] = [];
let currentSay: Statement | null = null;
const parseSegments = (lineText: string): Segment[] => {
const segments: Segment[] = [];
const segmentRegex = /([biuswcftn]|click|clicknoskip)?"([^"]*)"(\(([^)]+)\))?/g;
let match;
let lastIndex = 0;
while ((match = segmentRegex.exec(lineText)) !== null) {
// Add any text between matches (like spaces) as a plain segment
const gap = lineText.substring(lastIndex, match.index);
if (gap) {
segments.push({
text: gap,
modifiers: {
bold: false, italic: false, underline: false, strikethrough: false,
wait: false, waitDuration: null, color: null, font: null,
speed: null, clickLock: null, click: false, clicknoskip: false,
inlineBranch: []
}
});
}
const [full, mod, textValue, fullParens, params] = match;
const modifiers: Modifier = {
bold: mod === 'b',
italic: mod === 'i',
underline: mod === 'u',
strikethrough: mod === 's',
wait: mod === 'w',
waitDuration: mod === 'w' ? (params?.match(/w:([\d.s]+)/)?.[1] || '1s') : null,
color: mod === 'c' ? (params?.match(/c:([^)]+)/)?.[1] || null) : null,
font: mod === 'f' ? (params?.match(/f:([^)]+)/)?.[1] || null) : null,
speed: mod === 't' ? (params?.match(/t:([^)]+)/)?.[1] || null) : null,
clickLock: mod === 'n' ? (params?.match(/n:([^)]+)/)?.[1] || null) : null,
click: mod === 'click',
clicknoskip: mod === 'clicknoskip',
inlineBranch: []
};
segments.push({ text: textValue, modifiers });
lastIndex = segmentRegex.lastIndex;
}
// Add remaining text after the last match
const remaining = lineText.substring(lastIndex);
if (remaining) {
segments.push({
text: remaining,
modifiers: {
bold: false, italic: false, underline: false, strikethrough: false,
wait: false, waitDuration: null, color: null, font: null,
speed: null, clickLock: null, click: false, clicknoskip: false,
inlineBranch: []
}
});
}
return segments;
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
const originalLineNumber = i + 1;
if (!line || line.startsWith('//')) {
statements.push({ type: 'comment', text: line, lineNumber: originalLineNumber });
continue;
}
if (line.startsWith('say ') || line.startsWith('"')) {
if (line.startsWith('say ')) {
let character: string | null = null;
const afterSay = line.slice(4).trim();
let segmentPart = afterSay;
if (afterSay.startsWith('"')) {
character = null;
} else {
// Find the start of the first segment.
// A segment starts with a quote, or a modifier followed by a quote.
const segmentMatch = afterSay.match(/([biuswcftn]|click|clicknoskip)?"/);
if (segmentMatch && segmentMatch.index !== undefined) {
character = afterSay.substring(0, segmentMatch.index).trim();
segmentPart = afterSay.substring(segmentMatch.index);
} else {
character = afterSay;
segmentPart = "";
}
}
currentSay = {
type: 'say',
character,
segments: parseSegments(segmentPart),
continuations: [],
lineNumber: originalLineNumber
};
statements.push(currentSay);
} else if (line.startsWith('"') && currentSay) {
currentSay.continuations?.push(...parseSegments(line));
}
continue;
}
const words: string[] = line.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
const cmd = words[0];
if (cmd === 'label') {
statements.push({
type: 'label',
label: words[1],
lineNumber: originalLineNumber
});
} else if (cmd === 'choice') {
const text = words[1]?.replace(/^"(.*)"$/, '$1');
const jumpPart = words[2];
const jumpLabel = jumpPart?.startsWith('jump:') ? jumpPart.slice(5) : '';
const lastStatement = statements[statements.length - 1];
if (lastStatement?.type === 'choice') {
lastStatement.choices?.push({ text, jumpLabel });
} else {
statements.push({
type: 'choice',
choices: [{ text, jumpLabel }],
lineNumber: originalLineNumber
});
}
} else if (cmd === 'wait') {
statements.push({
type: 'wait',
duration: words[1],
skippable: words.includes('skippable'),
lineNumber: originalLineNumber
});
} else if (cmd === 'define_sprite') {
const char = words[1];
const id = words[2];
const url = words[3]?.replace(/^"(.*)"$/, '$1');
statements.push({
type: 'comment',
text: `DEF_SPRITE ${char} ${id} ${url}`,
lineNumber: originalLineNumber
});
} else if (cmd === 'sprite') {
const char = words[1];
const emotion = words[2];
const hasAt = words.indexOf('at');
const position = hasAt !== -1 ? words[hasAt + 1] : 'pos5';
statements.push({
type: 'sprite',
character: char,
emotion: emotion,
value: position,
lineNumber: originalLineNumber
});
} else if (cmd === 'set') {
statements.push({
type: 'set',
variable: words[1],
value: words.slice(2).join(' '),
lineNumber: originalLineNumber
});
} else if (cmd === 'fire') {
statements.push({
type: 'fire',
event: words[1],
args: words.slice(2),
lineNumber: originalLineNumber
});
} else if (cmd === 'nextfile') {
statements.push({
type: 'nextfile',
filename: words[1],
lineNumber: originalLineNumber
});
}
}
return statements;
}