diff --git a/src/lib/compiler/scriptBuilder.ts b/src/lib/compiler/scriptBuilder.ts index e7c21fcb7..08c4c4c29 100644 --- a/src/lib/compiler/scriptBuilder.ts +++ b/src/lib/compiler/scriptBuilder.ts @@ -48,7 +48,7 @@ import { isVariableTemp, toVariableNumber, } from "shared/lib/entities/entitiesHelpers"; -import { lexText } from "shared/lib/compiler/lexText"; +import { lexText, Token } from "shared/lib/compiler/lexText"; import type { Reference } from "components/forms/ReferencesSelect"; import { clone } from "lib/helpers/clone"; import { defaultVariableForContext } from "shared/lib/scripts/context"; @@ -82,6 +82,7 @@ import { gbvmScriptChecksum } from "./gbvm/buildHelpers"; import { generateScriptHash } from "shared/lib/scripts/scriptHelpers"; import { calculateTextBoxHeight } from "shared/lib/helpers/dialogue"; import { + chunkTextOnWaitCodes, parseWaitCodeFrames, splitTextOnWaitCodes, } from "shared/lib/text/textCodes"; @@ -1913,29 +1914,126 @@ class ScriptBuilder { )}${textCodeSetSpeed(2)}${textCodeGotoRel(1, -1)}${textCodeSetFont(0)}`; }; - _loadAndDisplayChunkedStructuredText = (inputText: string) => { + _loadAndDisplayText = (inputText: string) => { const waitArgsRef = this._declareLocal("wait_args", 1, true); let lastWait = -1; // Split into chunks where wait frames code is found - const textParts = splitTextOnWaitCodes(inputText); - for (let tp = 0; tp < textParts.length; tp++) { - const textPart = textParts[tp]; - const waitFrames = parseWaitCodeFrames(textPart); - // Replace wait codes with calls to wait_frames function - if (waitFrames !== undefined) { + const chunks = chunkTextOnWaitCodes(inputText); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + + this._loadTokens(chunk.tokens); + this._displayText(i !== 0); + + if (chunk.action?.type === "wait") { + const waitFrames = chunk.action.frames; this._overlayWait(true, [".UI_WAIT_TEXT"]); if (lastWait !== waitFrames) { this._setConst(waitArgsRef, Math.round(waitFrames)); lastWait = waitFrames; } this._invoke("wait_frames", 0, waitArgsRef); - } else { - this._loadStructuredText(textPart); - this._displayText(tp !== 0); } } }; + _loadTokens = (textTokens: Token[]) => { + const { fonts, defaultFontId } = this.options; + let font = fonts.find((f) => f.id === defaultFontId); + + if (!font) { + font = fonts[0]; + } + + if (!font) { + this._loadText(0); + this._string("UNABLE TO LOAD FONT"); + return; + } + + let text = ""; + const indirectVars: { arg: string; local: string }[] = []; + const usedVariableAliases: string[] = []; + + textTokens.forEach((token) => { + if (token.type === "text") { + text += encodeString(token.value, font?.mapping); + } else if (token.type === "font") { + const newFont = fonts.find((f) => f.id === token.fontId); + if (newFont) { + const fontIndex = this._getFontIndex(token.fontId); + font = newFont; + text += textCodeSetFont(fontIndex); + } + } else if ( + token.type === "variable" || + token.type === "char" || + token.type === "speedVariable" || + token.type === "fontVariable" + ) { + const variable = token.variableId; + if (variable.match(/^V[0-9]$/)) { + const key = variable; + const arg = this.options.argLookup.variable.get(key); + if (!arg) { + throw new Error("Cant find arg"); + } + if (this._isIndirectVariable(arg)) { + const localRef = this._declareLocal( + `text_arg${indirectVars.length}`, + 1, + true + ); + indirectVars.unshift({ + local: localRef, + arg: arg.symbol, + }); + usedVariableAliases.push(this._rawOffsetStackAddr(localRef)); + } else { + usedVariableAliases.push(this._rawOffsetStackAddr(arg.symbol)); + } + } else { + usedVariableAliases.push( + this.getVariableAlias(variable.replace(/^0/g, "")) + ); + } + if (token.type === "variable" && token.fixedLength !== undefined) { + text += `%D${token.fixedLength}`; + } else if (token.type === "variable") { + text += "%d"; + } else if (token.type === "char") { + text += "%c"; + } else if (token.type === "speedVariable") { + text += "%t"; + } else if (token.type === "fontVariable") { + text += "%f"; + } + } else if (token.type === "speed") { + text += textCodeSetSpeed(token.speed); + } else if (token.type === "gotoxy" && token.relative) { + text += textCodeGotoRel(token.x, token.y); + } else if (token.type === "gotoxy" && !token.relative) { + text += textCodeGoto(token.x, token.y); + } else if (token.type === "input") { + text += textCodeInput(token.mask); + } + }); + + if (indirectVars.length > 0) { + for (const indirectVar of indirectVars) { + this._getInd(indirectVar.local, indirectVar.arg); + } + } + + this._loadText(usedVariableAliases.length); + + if (usedVariableAliases.length > 0) { + this._dw(...usedVariableAliases); + } + + this._string(text); + }; + _loadStructuredText = (inputText: string) => { const { fonts, defaultFontId } = this.options; let font = fonts.find((f) => f.id === defaultFontId); @@ -4129,7 +4227,7 @@ extern void __mute_mask_${symbol}; avatarIndex )}${textPosSequence}${this._injectScrollCode(text, textHeight)}`; - this._loadAndDisplayChunkedStructuredText(decoratedText); + this._loadAndDisplayText(decoratedText); if (isModal) { const waitFlags: ScriptBuilderOverlayWaitFlag[] = [ @@ -4213,9 +4311,7 @@ extern void __mute_mask_${symbol}; this._setTextLayer(".TEXT_LAYER_BKG"); } - this._loadAndDisplayChunkedStructuredText( - `\\003\\${drawX}\\${drawY}\\001\\001${inputText}` - ); + this._loadAndDisplayText(`\\003\\${drawX}\\${drawY}\\001\\001${inputText}`); this._overlayWait(false, [".UI_WAIT_TEXT"]); @@ -4268,7 +4364,7 @@ extern void __mute_mask_${symbol}; this._overlayClear(0, 0, 20, numLines + 2, ".UI_COLOR_WHITE", true, true); this._overlayMoveTo(0, 18 - numLines - 2, ".OVERLAY_IN_SPEED"); - this._loadAndDisplayChunkedStructuredText(choiceText); + this._loadAndDisplayText(choiceText); this._overlayWait(true, [".UI_WAIT_WINDOW", ".UI_WAIT_TEXT"]); this._choice(dest, [".UI_MENU_LAST_0", ".UI_MENU_CANCEL_B"], 2); this._menuItem(1, 1, 0, 0, 0, 2); @@ -4332,7 +4428,7 @@ extern void __mute_mask_${symbol}; this._overlayMoveTo(10, 18, ".OVERLAY_SPEED_INSTANT"); } this._overlayMoveTo(x, 18 - height - 2, ".OVERLAY_IN_SPEED"); - this._loadAndDisplayChunkedStructuredText(menuText); + this._loadAndDisplayText(menuText); this._overlayWait(true, [".UI_WAIT_WINDOW", ".UI_WAIT_TEXT"]); this._choice(dest, choiceFlags, numLines); diff --git a/src/shared/lib/compiler/lexText.ts b/src/shared/lib/compiler/lexText.ts index 782a90345..fc7aeeb23 100644 --- a/src/shared/lib/compiler/lexText.ts +++ b/src/shared/lib/compiler/lexText.ts @@ -45,6 +45,8 @@ export type Token = | { type: "wait"; time: number; + units: "frames" | "time"; + frames: number; }; export const lexText = (inputText: string): Token[] => { @@ -315,11 +317,18 @@ export const lexText = (inputText: string): Token[] => { inputText[i + 1] === "W" && inputText[i + 2] === ":" ) { - const time = inputText.substring(i + 3, i + 8).replace(/!.*/, ""); - i += time.length + 3; + const timeString = inputText + .substring(i + 3, i + 8) + .replace(/[sf]![\s\S]*/, ""); + i += timeString.length + 4; + const units = inputText[i - 1] === "s" ? "time" : "frames"; + const time = ensureNumber(parseInt(timeString, 10), 30); + const frames = units === "time" ? time * 60 : time; tokens.push({ type: "wait", - time: ensureNumber(parseInt(time, 10), 30), + time, + units, + frames, }); continue; } diff --git a/src/shared/lib/text/textCodes.ts b/src/shared/lib/text/textCodes.ts index b55ed3709..09d9e7331 100644 --- a/src/shared/lib/text/textCodes.ts +++ b/src/shared/lib/text/textCodes.ts @@ -1,4 +1,5 @@ import { ensureNumber } from "shared/types"; +import { lexText, Token } from "shared/lib/compiler/lexText"; export const splitTextOnWaitCodes = (input: string): string[] => input.split(/(!W:[0-9]+[fs]!)/g); @@ -31,3 +32,58 @@ export const parseWaitCodeUnits = (input: string): "frames" | "time" => { } return "frames"; }; + +type TextAction = { + type: "wait"; + frames: number; +}; + +type TokenChunk = { + tokens: Token[]; + action?: TextAction; +}; + +export const chunkTokensOnWaitCodes = (tokens: Token[]): TokenChunk[] => { + if (tokens.length === 0) { + return []; + } + + // No wait tokens found so just return parsed tokens wrapped in array + if (!tokens.some((token) => token.type === "wait")) { + return [{ tokens }]; + } + + const output: TokenChunk[] = [{ tokens: [] }]; + + let lastSpeedToken: (Token & { type: "speed" | "speedVariable" }) | undefined; + let lastFontToken: (Token & { type: "font" | "fontVariable" }) | undefined; + + for (const token of tokens) { + if (token.type === "wait") { + output[output.length - 1].action = token; + output.push({ + tokens: [ + // Apply any speed / font changes from before split + ...(lastSpeedToken ? [lastSpeedToken] : []), + ...(lastFontToken ? [lastFontToken] : []), + ], + }); + } else { + // Track speed changes + if (token.type === "speed" || token.type === "speedVariable") { + lastSpeedToken = token; + } + // Track font changes + if (token.type === "font" || token.type === "fontVariable") { + lastFontToken = token; + } + output[output.length - 1].tokens.push(token); + } + } + + return output; +}; + +export const chunkTextOnWaitCodes = (input: string): TokenChunk[] => { + return chunkTokensOnWaitCodes(lexText(input)); +}; diff --git a/test/fonts/lexText.test.ts b/test/fonts/lexText.test.ts index d0eef857c..6a7c54923 100644 --- a/test/fonts/lexText.test.ts +++ b/test/fonts/lexText.test.ts @@ -135,3 +135,41 @@ test("should support font tokens", () => { }, ]); }); + +test("should support wait code tokens", () => { + expect(lexText("Before!W:5f!After")).toEqual([ + { + type: "text", + value: "Before", + }, + { + type: "wait", + time: 5, + units: "frames", + frames: 5, + }, + { + type: "text", + value: "After", + }, + ]); +}); + +test("should preserve newlines after wait code tokens", () => { + expect(lexText("Before!W:5f!\nAfter")).toEqual([ + { + type: "text", + value: "Before", + }, + { + type: "wait", + time: 5, + units: "frames", + frames: 5, + }, + { + type: "text", + value: "\nAfter", + }, + ]); +}); diff --git a/test/text/textCodes.test.ts b/test/text/textCodes.test.ts new file mode 100644 index 000000000..51c0e7102 --- /dev/null +++ b/test/text/textCodes.test.ts @@ -0,0 +1,352 @@ +import { Token } from "shared/lib/compiler/lexText"; +import { + chunkTextOnWaitCodes, + chunkTokensOnWaitCodes, +} from "shared/lib/text/textCodes"; + +describe("chunkTextOnWaitCodes", () => { + test("should split text into chunks when wait codes are found", () => { + const input = "hello!W:20s!world"; + const output = chunkTextOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); + + test("should preserve newlines after waits when split text into chunks", () => { + const input = "hello!W:20s!\nworld"; + const output = chunkTextOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { + type: "text", + value: "\nworld", + }, + ], + }, + ]); + }); +}); + +describe("chunkTokensOnWaitCodes", () => { + test("should split tokens into chunks when wait codes are found", () => { + const input: Token[] = [ + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); + + test("should preserve speed changes when chunking tokens", () => { + const input: Token[] = [ + { type: "speed", speed: 2 }, + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); + + test("should preserve speed changes across multiple chunks", () => { + const input: Token[] = [ + { type: "speed", speed: 2 }, + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + { type: "wait", time: 5, units: "frames", frames: 5 }, + { type: "text", value: "more" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "world", + }, + ], + action: { + type: "wait", + time: 5, + units: "frames", + frames: 5, + }, + }, + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "more", + }, + ], + }, + ]); + }); + + test("should preserve only latest speed changes", () => { + const input: Token[] = [ + { type: "speed", speed: 2 }, + { type: "text", value: "hello" }, + { type: "speed", speed: 4 }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + { type: "wait", time: 5, units: "frames", frames: 5 }, + { type: "text", value: "more" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "speed", speed: 2 }, + { + type: "text", + value: "hello", + }, + { type: "speed", speed: 4 }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "speed", speed: 4 }, + { + type: "text", + value: "world", + }, + ], + action: { + type: "wait", + time: 5, + units: "frames", + frames: 5, + }, + }, + { + tokens: [ + { type: "speed", speed: 4 }, + { + type: "text", + value: "more", + }, + ], + }, + ]); + }); + + test("should preserve speed variable changes when chunking tokens", () => { + const input: Token[] = [ + { type: "speedVariable", variableId: "3" }, + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "speedVariable", variableId: "3" }, + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "speedVariable", variableId: "3" }, + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); + + test("should preserve font changes when chunking tokens", () => { + const input: Token[] = [ + { type: "font", fontId: "font1" }, + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "font", fontId: "font1" }, + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "font", fontId: "font1" }, + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); + + test("should preserve font variable changes when chunking tokens", () => { + const input: Token[] = [ + { type: "fontVariable", variableId: "4" }, + { type: "text", value: "hello" }, + { type: "wait", time: 20, units: "time", frames: 1200 }, + { type: "text", value: "world" }, + ]; + const output = chunkTokensOnWaitCodes(input); + expect(output).toEqual([ + { + tokens: [ + { type: "fontVariable", variableId: "4" }, + { + type: "text", + value: "hello", + }, + ], + action: { + type: "wait", + time: 20, + units: "time", + frames: 1200, + }, + }, + { + tokens: [ + { type: "fontVariable", variableId: "4" }, + { + type: "text", + value: "world", + }, + ], + }, + ]); + }); +});