// --- 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 = line.match(/(?:[^\s"]+|"[^"]*")+/g) || []; let 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; }