291 lines
9.1 KiB
TypeScript
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;
|
|
}
|