feat: player runs the game's own text tags, layers and camera, real preferences and Color

Layers (config.layers, onlayer, add_layer, show_layer_at, camera with perspective), the
game's config.custom_text_tags called like Ren'Py does (the built-in {bt}/{fi}/... copies
are gone), built-in {font} {alpha} {k} {cps} {a} {rb}/{rt} {space} {alt} {image}, FontGroup,
mouse displayable, renpy.fetch, preferences backed by the player's settings, Color class,
Render.subsurface/zoom, `default obj.attr` on provided objects, and a repaint loop that
survives text being laid out again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-27 04:39:48 +03:00
1 parent 5da7ab1a90
commit a7034b3563
13 files changed
+1563 -710

No files matched your search

+80
View File
@@ -193,3 +193,83 @@ test("starred items spread into tuples and lists", () => {
assert.deepEqual(spread, [1, 2, 3, 4]);
} finally { engine.destroy(); }
});
test("pictures on a named layer stay on it, layers stack as config.layers says, and a save keeps both", () => {
const { engine } = makeEngine(runtime());
try {
engine.newStore();
engine.config.layers = ["bottom", "master", "screens", "top"];
engine.syncLayers();
const picture = engine.resolveImage(["background"]);
engine.renderer.show(picture, "bg", [], {});
engine.renderer.show(picture, "cloud", [], { layer: "top" });
engine.renderer.show(picture, "floor", [], { layer: "bottom" });
const depth = (name) => Number(engine.renderer.stage.querySelector(`[data-layer=${name}]`).style.zIndex);
assert.ok(depth("bottom") < depth("master") && depth("master") < 0 && depth("top") > 0);
assert.equal(engine.renderer.isShown("cloud"), false);
assert.equal(engine.renderer.isShown("cloud", "top"), true);
engine.renderer.layerAt("top", [{ props: { xoffset: 30 } }]);
const saved = structuredClone(engine.snapshotState());
assert.deepEqual(saved.scene.map((s) => [s.tag, s.layer ?? "master"]).sort(), [["bg", "master"], ["cloud", "top"], ["floor", "bottom"]]);
assert.deepEqual(saved.layers, { top: [{ props: { xoffset: 30 } }] });
engine.newStore();
engine.applySave(saved);
assert.equal(engine.renderer.isShown("cloud", "top"), true);
assert.equal(engine.renderer.isShown("floor", "bottom"), true);
engine.renderer.scene("top");
assert.equal(engine.renderer.isShown("cloud", "top"), false);
assert.equal(engine.renderer.isShown("bg"), true);
} finally { engine.destroy(); }
});
test("a layer added by the game is part of the save and comes back with it", () => {
const { engine } = makeEngine(runtime());
try {
engine.newStore();
engine.builtins.renpy.add_layer("hud", { __kw: { above: "master" } });
assert.deepEqual(engine.config.layers, ["master", "hud", "transient", "screens", "overlay"]);
const saved = structuredClone(engine.snapshotState());
engine.newStore();
assert.deepEqual(engine.config.layers, ["master", "transient", "screens", "overlay"]);
engine.applySave(saved);
assert.deepEqual(engine.config.layers, ["master", "hud", "transient", "screens", "overlay"]);
} finally { engine.destroy(); }
});
test("preferences are the player's settings", () => {
const { engine } = makeEngine(runtime());
try {
engine.newStore();
const prefs = engine.builtins.preferences;
assert.equal(prefs.text_cps, engine.settings.cps);
prefs.text_cps = 20;
assert.equal(engine.settings.cps, 20);
prefs.set_volume("music", 0.25);
assert.equal(prefs.get_volume("music"), 0.25);
prefs.set_mute("music", true);
assert.equal(prefs.get_volume("music"), 0);
assert.equal(prefs.get_mute("music"), true);
prefs.set_mute("music", false);
assert.equal(prefs.get_volume("music"), 0.25);
engine.py.assignPath("preferences.chaos_on", true); // `default preferences.chaos_on = True`
assert.equal(prefs.chaos_on, true);
assert.equal(engine.builtins.preferences, prefs);
} finally { engine.destroy(); }
});
test("Rollback goes back one interaction", async () => {
const { engine } = makeEngine(runtime());
try {
engine.newStore();
engine.running = true;
engine.cur = { label: "start", path: [0] };
engine.store.n = 1;
engine.interactionStart();
engine.store.n = 2;
engine.cur = { label: "start", path: [0] };
engine.interactionStart();
assert.equal(engine.rollbackLog.length, 2);
engine.rollback();
assert.equal(engine.store.n, 1);
} finally { engine.destroy(); }
});
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/text.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const text = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const { parseText, textHooks, plainText, waitPoints, cpsPoints, TEXT_TEXT, TEXT_TAG, TEXT_DISPLAYABLE } = text;
test("a game's own text tag gets the text inside it and answers with what is shown", () => {
const calls = [];
textHooks.has = (name) => name === "shout";
textHooks.call = (name, argument, contents) => {
calls.push([name, argument, contents]);
return [[TEXT_TAG, "b"], ...contents.map(([kind, value]) => [kind, kind === TEXT_TEXT ? value.toUpperCase() : value]), [TEXT_TAG, "/b"]];
};
const parsed = parseText("a {shout=3}hi {i}there{/i}{/shout} z");
assert.deepEqual(calls, [["shout", "3", [[TEXT_TEXT, "hi "], [TEXT_TAG, "i"], [TEXT_TEXT, "there"], [TEXT_TAG, "/i"]]]]);
assert.equal(plainText(parsed), "a HI THERE z");
const bold = parsed.segments.filter((s) => s.kind === "text" && s.style.b).map((s) => s.text);
assert.deepEqual(bold, ["HI ", "THERE"]);
// without the game's functions (history, saves) the text is shown as written
assert.equal(plainText(parseText("a {shout=3}hi{/shout} z", false)), "a hi z");
textHooks.has = undefined;
textHooks.call = undefined;
});
test("a displayable a tag returns is one character wide", () => {
const picture = { id: "picture" };
textHooks.has = (name) => name === "sparkle";
textHooks.selfClosing = (name) => name === "sparkle";
textHooks.call = () => [[TEXT_TEXT, "{x}"], [TEXT_DISPLAYABLE, picture]];
const parsed = parseText("ab{sparkle}cd");
assert.equal(parsed.length, 2 + 3 + 1 + 2);
const shown = parsed.segments.find((s) => s.kind === "display");
assert.equal(shown.value, picture);
assert.equal(plainText(parsed), "ab{x}cd");
textHooks.has = textHooks.selfClosing = textHooks.call = undefined;
});
test("built-in tags: space, cps, alt, font, alpha, kerning, links", () => {
const parsed = parseText("a{space=20}b{cps=*2}c{w=1}d{/cps}{alt}hidden{/alt}{font=f.ttf}x{/font}{k=2}y{/k}{a=jump:go}z{/a}");
assert.equal(plainText(parsed), "abcdxyz");
assert.deepEqual(parsed.segments.find((s) => s.kind === "gap"), { kind: "gap", width: 20, height: 0 });
assert.deepEqual(cpsPoints(parsed), [{ at: 2, value: 2, relative: true }, { at: 4, value: null, relative: false }]);
assert.deepEqual(waitPoints(parsed), [{ at: 3, seconds: 1 }]);
const style = (letter) => parsed.segments.find((s) => s.kind === "text" && s.text === letter).style;
assert.equal(style("x").font, "f.ttf");
assert.equal(style("y").k, "2");
assert.equal(style("z").link, "jump:go");
});
+4 -4
View File
@@ -41,15 +41,15 @@ test("highlight wraps glossary terms case-insensitively and escapes regex charac
assert.equal(highlight("plain", []), "plain");
});
test("custom tags: letters of {bt} and {sc} move, a closing tag closes only its own tag, unknown pairs stay plain", async () => {
test("tags the game does not run stay plain, and a closing tag closes only its own tag", async () => {
const { build } = await import("esbuild");
const { fileURLToPath } = await import("node:url");
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/text.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { parseText, plainText } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const parsed = parseText("a{b}{bt=8}jump{/bt} still bold{/b} plain {chaos}odd{/chaos} end");
const styles = parsed.segments.filter((s) => s.kind === "text").map((s) => [s.text, !!s.style.b, s.style.fx ?? null]);
assert.deepEqual(styles, [["a", false, null], ["jump", true, "bt"], [" still bold", true, null], [" plain ", false, null],
["odd", false, null], [" end", false, null]]);
const styles = parsed.segments.filter((s) => s.kind === "text").map((s) => [s.text, !!s.style.b]);
assert.deepEqual(styles, [["a", false], ["jump", true], [" still bold", true], [" plain ", false],
["odd", false], [" end", false]]);
assert.equal(plainText(parsed), "ajump still bold plain odd end");
});