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
12 files changed
+1185 -332

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
// Ren'Py's `Color` (renpy/color.py): a tuple of four whole numbers 0..255 (red, green, blue, alpha) with the colour
// arithmetic games use: interpolation, tint and shade, replacing one component, hexcode, rgb/hls/hsv views.
const byte = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const unit = (v: number) => Math.max(0, Math.min(1, v));
function rgbToHls(r: number, g: number, b: number): [number, number, number] {
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (min + max) / 2;
if (min === max) return [0, l, 0];
const d = max - min;
const s = l <= 0.5 ? d / (max + min) : d / (2 - max - min);
const rc = (max - r) / d, gc = (max - g) / d, bc = (max - b) / d;
const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc;
return [(((h / 6) % 1) + 1) % 1, l, s];
}
function hlsToRgb(h: number, l: number, s: number): [number, number, number] {
if (s === 0) return [l, l, l];
const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s;
const m1 = 2 * l - m2;
const channel = (hue: number) => {
hue = ((hue % 1) + 1) % 1;
if (hue < 1 / 6) return m1 + (m2 - m1) * hue * 6;
if (hue < 0.5) return m2;
if (hue < 2 / 3) return m1 + (m2 - m1) * (2 / 3 - hue) * 6;
return m1;
};
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const v = max;
if (min === max) return [0, 0, v];
const d = max - min;
const s = d / max;
const rc = (max - r) / d, gc = (max - g) / d, bc = (max - b) / d;
const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc;
return [(((h / 6) % 1) + 1) % 1, s, v];
}
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
if (s === 0) return [v, v, v];
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s), q = v * (1 - s * f), t = v * (1 - s * (1 - f));
return ([[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]] as [number, number, number][])[((i % 6) + 6) % 6];
}
export class RenpyColor extends Array<number> {
static get [Symbol.species]() { return Array; }
readonly __renpyColor = true;
static from4(r: number, g: number, b: number, a = 255): RenpyColor {
const color = new RenpyColor();
color.push(byte(r), byte(g), byte(b), byte(a));
return color;
}
static fromUnit(r: number, g: number, b: number, a = 1): RenpyColor {
return RenpyColor.from4(unit(r) * 255, unit(g) * 255, unit(b) * 255, unit(a) * 255);
}
get alpha() { return this[3] / 255; }
get rgb(): [number, number, number] { return [this[0] / 255, this[1] / 255, this[2] / 255]; }
get hls() { return rgbToHls(...this.rgb); }
get hsv() { return rgbToHsv(...this.rgb); }
/** "#rrggbb", or "#rrggbbaa" when it is not opaque. */
get hexcode(): string {
const part = (v: number) => v.toString(16).padStart(2, "0");
return `#${part(this[0])}${part(this[1])}${part(this[2])}${this[3] === 255 ? "" : part(this[3])}`;
}
toString() { return this.hexcode; }
replace_hue(hue: number, alpha?: number) {
const [, l, s] = this.hls;
return RenpyColor.fromUnit(...hlsToRgb(hue, l, s), alpha ?? this.alpha);
}
replace_lightness(lightness: number, alpha?: number) {
const [h, , s] = this.hls;
return RenpyColor.fromUnit(...hlsToRgb(h, lightness, s), alpha ?? this.alpha);
}
replace_saturation(saturation: number, alpha?: number) {
const [h, l] = this.hls;
return RenpyColor.fromUnit(...hlsToRgb(h, l, saturation), alpha ?? this.alpha);
}
replace_value(value: number, alpha?: number) {
const [h, s] = this.hsv;
return RenpyColor.fromUnit(...hsvToRgb(h, s, value), alpha ?? this.alpha);
}
replace_opacity(opacity: number) { return RenpyColor.from4(this[0], this[1], this[2], unit(opacity) * 255); }
/** Multiplies the alpha by `opacity`. */
opacity(opacity: number) { return RenpyColor.from4(this[0], this[1], this[2], this[3] * unit(opacity)); }
/** Blends toward `other` (by `fraction`, 0 is this colour) in the RGB colour space. */
interpolate(other: any, fraction: number) {
const o = toColor(other);
const f = Number(fraction);
return RenpyColor.from4(...([0, 1, 2, 3].map((i) => this[i] + (o[i] - this[i]) * f) as [number, number, number, number]));
}
interpolate_hsv(other: any, fraction: number) {
const o = toColor(other);
const f = Number(fraction);
const [h1, s1, v1] = this.hsv, [h2, s2, v2] = o.hsv;
let dh = h2 - h1; // the short way round the colour wheel
if (dh > 0.5) dh -= 1;
else if (dh < -0.5) dh += 1;
return RenpyColor.fromUnit(...hsvToRgb(((h1 + dh * f) % 1 + 1) % 1, s1 + (s2 - s1) * f, v1 + (v2 - v1) * f),
this.alpha + (o.alpha - this.alpha) * f);
}
/** Towards white by `fraction`. */
tint(fraction: number) { return this.interpolate(RenpyColor.from4(255, 255, 255, this[3]), fraction); }
/** Towards black by `fraction`. */
shade(fraction: number) { return this.interpolate(RenpyColor.from4(0, 0, 0, this[3]), fraction); }
multiply(other: any) {
const o = toColor(other);
return RenpyColor.from4(...([0, 1, 2, 3].map((i) => (this[i] * o[i]) / 255) as [number, number, number, number]));
}
/** `color * color` multiplies channel by channel; `color * number` scales the colour channels. */
__binop__(op: string, other: any) {
if (op === "*" && typeof other === "number") return RenpyColor.from4(this[0] * other, this[1] * other, this[2] * other, this[3]);
if (op === "*") return this.multiply(other);
if (op === "+" || op === "-") {
const o = toColor(other);
const sign = op === "+" ? 1 : -1;
return RenpyColor.from4(this[0] + sign * o[0], this[1] + sign * o[1], this[2] + sign * o[2], this[3]);
}
throw new TypeError(`Color ${op}`);
}
}
/** `Color(...)` and every place that takes "a colour": a string, a tuple, or a Color. */
export function toColor(value: any): RenpyColor {
if (value instanceof RenpyColor) return value;
if (Array.isArray(value) && value.length >= 3) {
return RenpyColor.from4(Number(value[0]), Number(value[1]), Number(value[2]), value.length > 3 ? Number(value[3]) : 255);
}
if (typeof value === "string") {
const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value.trim());
if (m) {
let hex = m[1];
if (hex.length <= 4) hex = [...hex].map((c) => c + c).join("");
const part = (i: number) => parseInt(hex.slice(i, i + 2), 16);
return RenpyColor.from4(part(0), part(2), part(4), hex.length === 8 ? part(6) : 255);
}
const named = NAMED[value.trim().toLowerCase()];
if (named) return toColor(named);
}
throw new Error(`Не удалось разобрать цвет: ${String(value)}`);
}
const NAMED: Record<string, string> = {
black: "#000000", white: "#ffffff", red: "#ff0000", green: "#008000", blue: "#0000ff", yellow: "#ffff00",
cyan: "#00ffff", magenta: "#ff00ff", gray: "#808080", grey: "#808080", orange: "#ffa500", purple: "#800080",
pink: "#ffc0cb", brown: "#a52a2a", transparent: "#00000000",
};
/** The Python-facing `Color(color=None, hls=None, hsv=None, rgb=None, alpha=1.0)`. */
export function makeColor(args: any[]): RenpyColor {
const last = args[args.length - 1];
const kw: Record<string, any> = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
const positional = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? args.slice(0, -1) : args;
const color = positional[0] ?? kw.color;
const alpha = positional[1] ?? kw.alpha;
let made: RenpyColor;
if (kw.hls) made = RenpyColor.fromUnit(...hlsToRgb(kw.hls[0], kw.hls[1], kw.hls[2]), kw.hls[3] ?? 1);
else if (kw.hsv) made = RenpyColor.fromUnit(...hsvToRgb(kw.hsv[0], kw.hsv[1], kw.hsv[2]), kw.hsv[3] ?? 1);
else if (kw.rgb) made = RenpyColor.fromUnit(kw.rgb[0], kw.rgb[1], kw.rgb[2], kw.rgb[3] ?? 1);
else made = toColor(color);
return alpha === undefined || alpha === null ? made : made.opacity(Number(alpha));
}
/** A CSS value for a colour the game computed (a Color, a tuple or a string), or undefined. */
export function cssColor(value: any): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") return value;
try { return toColor(value).hexcode; } catch { return undefined; }
}
+333 -67
View File
@@ -6,18 +6,18 @@ import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { cpsPoints, fastPoint, interpolate, parseText, plainText, renderText, TEXT_DISPLAYABLE, TEXT_PARAGRAPH, TEXT_TAG, TEXT_TEXT, textHooks, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel";
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
import { textSettings } from "./texteffects";
import { containerClass, displayableClass, makeRender, pygameNamespace } from "./render";
import { musicRoomClass } from "./musicroom";
import { pythonModules } from "./pymodules";
import { cssColor } from "./colors";
import { parseExpression, parseStatements } from "./pyparse";
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
AtSpec, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types";
export type { SaveApi, SaveMeta } from "./engine-util";
@@ -41,16 +41,31 @@ function toJson(value: any): any {
return value;
}
/** `FontGroup().add(file, first, last)`: the browser picks fonts by letter itself, so the first file stands for the group. */
/** `FontGroup().add(file, first, last)`: each letter takes the first font whose range holds it. */
const fontGroups = new Map<string, { file: string; first: number; last: number }[]>();
function fontGroup() {
const group = { files: [] as string[], add: (file: any, ..._range: any[]) => { group.files.push(pyStr(file)); return group; },
toString: () => group.files[0] ?? "" };
const id = `@group:${fontGroups.size}`;
const ranges: { file: string; first: number; last: number }[] = [];
fontGroups.set(id, ranges);
const code = (v: any) => (typeof v === "number" ? v : String(v).codePointAt(0) ?? 0);
const group = {
add: (file: any, first: any = 0, last: any = 0x10ffff) => {
ranges.push({ file: pyStr(file), first: code(first), last: code(last) });
return group;
},
add_range: (file: any, first: any, last: any) => group.add(file, first, last),
toString: () => id,
};
return group;
}
/** How many interactions Rollback can step back through. */
const ROLLBACK_LIMIT = 50;
/** config.layers before the game says otherwise. */
const DEFAULT_LAYERS = ["master", "transient", "screens", "overlay"];
export class Engine {
private liveImages?: LiveImages;
private imageScales = new Map<string, number>();
@@ -87,6 +102,8 @@ export class Engine {
private lastSay: { name: string; text: string; color?: string; char?: CharacterDef } | null = null;
private history: { name: string; text: string }[] = [];
/** One state per interaction, newest last: what Rollback steps back through. */
private config: Record<string, any> = {};
private addedLayers: { name: string; above: string | null; below: string | null }[] = [];
private rollbackLog: { snap: SaveData; history: { name: string; text: string }[] }[] = [];
private nvlLines: HTMLElement[] = [];
private interactions = 0;
@@ -164,9 +181,10 @@ export class Engine {
this.py.blockingNames = new Set([
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
"ui.interact", "renpy.movie_cutscene",
"ui.interact", "renpy.movie_cutscene", "renpy.fetch",
]);
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
this.installTextHooks();
const [w, h] = game.resolution;
this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306);
@@ -506,8 +524,6 @@ export class Engine {
predicting: () => false,
rollback: () => null,
invoke_in_new_context: (fn: any, ...a: any[]) => (typeof fn === "function" ? fn(...a) : null),
add_layer: () => null,
show_layer_at: () => null,
filter_text_tags: (text: any) => pyStr(text).replace(/\{[^}]*\}/g, ""),
text: { text: { Text: (...a: any[]) => new Displayable("text", a, {}) } },
easy: { displayable_or_none: (d: any) => d ?? null, displayable: (d: any) => d },
@@ -535,8 +551,7 @@ export class Engine {
displayable: (d: any) => d,
IgnoreEvent: () => ({ __ignore: true }),
restart_interaction: () => { this.screens.refresh(); return null; },
// there is no network access from the player: games that fetch news handle the failure themselves
fetch: () => { throw new Error("IOError: сеть недоступна"); },
fetch: (url: any, ...a: any[]) => new BlockingCall("fetch", [url], kwOf(a)),
display: { matrix: { Matrix: () => ({}), offset: () => ({}), rotate: () => ({}), perspective: () => ({}), texture_projection: () => ({}), screen_projection: () => ({}) },
layout: { AdjustTimes: (child: any) => child, Motion: (_move: any, _period: any, child: any) => child,
Container: containerClass(this.displayableBase, () => this.py) },
@@ -588,25 +603,42 @@ export class Engine {
if (kw.what !== undefined && kw.what !== null) this.registerImage(parts[0], this.imageDefinition(kw.what));
const picture = this.resolveImage(parts);
if (!picture) return null;
const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({
name: t.__transform, ...(this.game.atl?.[t.__transform] ? { atl: t.__transform } : {}),
...(t.args?.length ? { args: t.args.map((v: any) => ({ k: "const", v })) } : {}) }));
this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined });
this.renderer.show(picture, kw.tag ?? parts[0], this.atFromList(kw.at_list), {
zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined, layer: kw.layer ? pyStr(kw.layer) : undefined });
return null;
},
hide: (name: any) => {
this.renderer.hide(String(name).split(" ")[0]);
hide: (name: any, ...a: any[]) => {
const layer = kwOf(a).layer ?? kwless(a)[0];
this.renderer.hide(String(name).split(" ")[0], layer ? pyStr(layer) : undefined);
return null;
},
scene: () => {
this.renderer.scene();
this.clearNvl();
scene: (...a: any[]) => {
const layer = kwOf(a).layer ?? kwless(a)[0];
this.renderer.scene(layer ? pyStr(layer) : undefined);
if (!layer || pyStr(layer) === "master") this.clearNvl();
return null;
},
TEXT_TEXT, TEXT_TAG, TEXT_PARAGRAPH, TEXT_DISPLAYABLE,
add_layer: (name: any, ...a: any[]) => {
const kw = kwOf(a), rest = kwless(a);
this.addLayer(pyStr(name), kw.above ?? rest[0] ?? null, kw.below ?? rest[1] ?? null);
return null;
},
show_layer_at: (list: any, ...a: any[]) => {
const layer = kwOf(a).layer ?? kwless(a)[0] ?? "master";
this.renderer.layerAt(pyStr(layer), this.atFromList(Array.isArray(list) ? list : list ? [list] : []));
return null;
},
has_label: (l: string) => this.labels.has(l),
seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)),
showing: (tag: string) => this.renderer.isShown(String(tag).split(" ")[0]),
get_attributes: (tag: string) => this.renderer.attrsOf(tag)?.slice(1) ?? [],
showing: (tag: string, ...a: any[]) => {
const layer = kwOf(a).layer ?? kwless(a)[0];
return this.renderer.isShown(String(tag).split(" ")[0], layer ? pyStr(layer) : undefined);
},
get_attributes: (tag: string, ...a: any[]) => {
const layer = kwOf(a).layer ?? kwless(a)[0];
return this.renderer.attrsOf(tag, layer ? pyStr(layer) : undefined)?.slice(1) ?? [];
},
full_restart: () => {
throw new JumpSignal("jump", "__main_menu__");
},
@@ -614,7 +646,10 @@ export class Engine {
throw new JumpSignal("jump", "__quit__");
},
version: () => "pgvn",
get_showing_tags: () => this.renderer.snapshot().map((image) => image.tag),
get_showing_tags: (...a: any[]) => {
const layer = pyStr(kwOf(a).layer ?? kwless(a)[0] ?? "master");
return this.renderer.snapshot().filter((image) => (image.layer ?? "master") === layer).map((image) => image.tag);
},
run: (action: any) => {
this.screens.run(action, null);
return null;
@@ -698,10 +733,11 @@ export class Engine {
return {
renpy,
persistent: this.persistent,
config: { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1] },
config: (this.config = { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1],
layers: [...DEFAULT_LAYERS], custom_text_tags: new PyDict(), self_closing_custom_text_tags: new PyDict() }),
// games branch on the Ren'Py language name ("russian"); None means the source language
preferences: this.languagePrefs(),
_preferences: this.languagePrefs(),
preferences: (this.preferences = this.makePreferences()),
_preferences: this.preferences,
Layer: (name: any) => ({ __layer: pyStr(name) }),
gui: { rebuild: () => null },
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
@@ -747,6 +783,8 @@ export class Engine {
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
str: (x: any) => pyStr(x),
unicode: (x: any) => pyStr(x), // Ren'Py keeps the Python 2 names for old scripts
basestring: (x: any) => pyStr(x),
int: (x: any) => Math.trunc(num(x)),
float: (x: any) => num(x),
bool: (x: any) => truthy(x),
@@ -811,17 +849,77 @@ export class Engine {
};
}
private languagePrefs() {
private preferences: Record<string, any> = {};
private muted = new Map<string, number>();
/** A mixer's volume from the settings screen or from `preferences.set_volume`: it is heard now and kept for next time. */
private setMixerValue(name: string, value: number) {
this.audio.setMixer(name, value);
const key = name === "sfx" ? "sound" : name;
if (key in this.settings) {
(this.settings as any)[key] = value;
this.audio.setVolumes(this.settings);
this.persistSettings("audio");
}
}
/** Ren'Py's `preferences`: what the player's settings are, read and written by the games' own settings screens. */
private makePreferences(): Record<string, any> {
const engine = this;
return {
get language() {
return engine.tr.renpyName;
},
get_mute: () => false,
get_volume: () => 1,
set_volume: () => null,
set_mute: () => null,
const mixerNames = () => ["music", "sfx", "voice", ...Object.values(this.game.channels ?? {}).map((c) => c.mixer)]
.filter((name, index, all) => all.indexOf(name) === index);
const volume = (mixer: any) => this.muted.has(String(mixer)) ? 0 : this.audio.mixerVolume(String(mixer));
const setVolume = (mixer: any, value: any) => {
const name = String(mixer);
if (this.muted.has(name)) this.muted.set(name, Number(value));
else this.setMixerValue(name, Number(value));
return null;
};
const setMute = (mixer: any, on: any) => {
const name = String(mixer);
if (truthy(on) && !this.muted.has(name)) {
this.muted.set(name, this.audio.mixerVolume(name));
this.audio.setMixer(name, 0);
} else if (!truthy(on) && this.muted.has(name)) {
this.audio.setMixer(name, this.muted.get(name)!);
this.muted.delete(name);
}
return null;
};
// `preferences.volumes["music"]`
const volumes = new PyDict();
volumes.get = (k: any) => volume(k);
volumes.set = (k: any, v: any) => { setVolume(k, v); };
volumes.has = (k: any) => mixerNames().includes(String(k));
const prefs: Record<string, any> = {
skip_unseen: false, skip_after_choices: false, mouse_move: true, self_voicing: false,
emphasize_audio: false, voice_sustain: false, show_empty_window: true, system_cursor: false,
desktop_rollback_side: "disable", mobile_rollback_side: "disable", gl_framerate: null, gl_powersave: true,
gl_tearing: false, wait_voice: true, using_afm_enable: true, volumes,
get_volume: volume, set_volume: setVolume,
get_mixer: volume, set_mixer: setVolume,
get_mute: (mixer: any) => this.muted.has(String(mixer)),
set_mute: setMute,
get_all_mixers: () => mixerNames(),
reset: () => null,
};
const accessors: Record<string, PropertyDescriptor> = {
language: { get: () => engine.tr.renpyName, set: (value) => { void engine.setLanguage(value === null ? engine.game.sourceLang ?? "" : String(value)); } },
// characters per second: 0 shows a line at once
text_cps: { get: () => engine.settings.cps, set: (value) => { engine.settings.cps = Math.max(0, Number(value) || 0); engine.persistSettings("reading"); } },
afm_time: { get: () => engine.settings.autoDelay, set: (value) => { engine.settings.autoDelay = Math.max(0, Number(value) || 0); engine.persistSettings("reading"); } },
transitions: { get: () => (engine.renderer.transitionsOff ? 0 : 2), set: (value) => { engine.renderer.transitionsOff = Number(value) === 0; } },
afm_enable: { get: () => engine.auto, set: (value) => engine.setAuto(truthy(value)) },
fullscreen: {
get: () => Boolean(document.fullscreenElement),
set: (value) => {
if (truthy(value) === Boolean(document.fullscreenElement)) return;
void (truthy(value) ? engine.container.requestFullscreen?.() : document.exitFullscreen?.())?.catch?.(() => {});
},
},
};
for (const [name, descriptor] of Object.entries(accessors)) Object.defineProperty(prefs, name, { ...descriptor, enumerable: true, configurable: true });
return prefs;
}
private async setLanguage(lang: string) {
@@ -847,7 +945,7 @@ export class Engine {
const speaker: any = (...b: any[]) => new BlockingCall("say", [speaker, posOf(b)[0]], kwOf(b));
speaker.__character = true;
speaker.name = typeof posOf(a)[0] === "string" ? posOf(a)[0] : null;
speaker.color = kw.color ?? null;
speaker.color = cssColor(kw.color) ?? null;
return speaker;
};
// transitions are values in Python (`renpy.with_statement(dissolve)`), transforms are named by at_list
@@ -859,7 +957,7 @@ export class Engine {
Object.assign(out, transitions);
out.Dissolve = (d = 0.5) => ({ t: "dissolve", d });
out.Fade = (outTime = 0.5, hold = 0, inTime = 0.5, ...a: any[]) =>
({ t: "fade", out: outTime, hold, in: inTime, color: kwOf(a).color ?? "#000000" });
({ t: "fade", out: outTime, hold, in: inTime, color: cssColor(kwOf(a).color) ?? "#000000" });
for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}),
"left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft",
"offscreenright"]) {
@@ -889,6 +987,41 @@ export class Engine {
return step.value;
}
/**
* `renpy.fetch`: the request goes out from the browser, so the server has to allow it (CORS); when it does not, or the
* network is down, the game gets the error it would get from Ren'Py (`FetchError`) and handles it as it does there.
*/
private async fetchUrl(url: string, kw: Record<string, any>): Promise<any> {
const method = kw.method ? pyStr(kw.method).toUpperCase() : kw.data !== undefined || kw.json !== undefined ? "POST" : "GET";
const target = new URL(url, location.href);
for (const [key, value] of Object.entries(kw.params ?? {})) target.searchParams.set(key, pyStr(value));
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(kw.headers ?? {})) headers[key] = pyStr(value);
let body: any;
if (kw.json !== undefined && kw.json !== null) {
body = JSON.stringify(toJson(kw.json));
headers["Content-Type"] ??= "application/json";
} else if (kw.data !== undefined && kw.data !== null) {
body = Array.isArray(kw.data) ? new Uint8Array(kw.data) : pyStr(kw.data);
if (kw.content_type) headers["Content-Type"] = pyStr(kw.content_type);
}
const control = new AbortController();
const timer = window.setTimeout(() => control.abort(), (Number(kw.timeout) || 5) * 1000);
try {
const response = await fetch(target, { method, headers, body, signal: control.signal });
if (!response.ok) throw new Error(`FetchError: HTTP ${response.status}`);
const result = kw.result ? pyStr(kw.result) : "bytes";
if (result === "json") return fromJson(await response.json());
if (result === "text") return await response.text();
return [...new Uint8Array(await response.arrayBuffer())]; // bytes are lists of numbers here
} catch (err: any) {
if (err?.message?.startsWith("FetchError")) throw err;
throw new Error(`FetchError: ${err?.name === "AbortError" ? "время ожидания истекло" : "нет ответа от сервера"}`);
} finally {
window.clearTimeout(timer);
}
}
private async answerBlocking(request: BlockingCall): Promise<any> {
const [first, second] = request.args;
switch (request.kind) {
@@ -925,6 +1058,8 @@ export class Engine {
}
case "movie":
return this.movieCutscene(request.args, request.kw);
case "fetch":
return this.fetchUrl(pyStr(first), request.kw);
}
throw new Error(`Неизвестный запрос ${request.kind}`);
}
@@ -1043,15 +1178,7 @@ export class Engine {
return {
error: (message) => this.fail(new Error(message)),
get audio() { return engine.audio; },
setMixer: (name, value) => {
this.audio.setMixer(name, value);
const key = name === "sfx" ? "sound" : name;
if (key in this.settings) {
(this.settings as any)[key] = value;
this.audio.setVolumes(this.settings);
this.persistSettings("audio");
}
},
setMixer: (name, value) => this.setMixerValue(name, value),
py: this.py,
fileUrl: (path) => this.fileUrl(path),
image: (value) => this.screenImage(value),
@@ -1066,6 +1193,7 @@ export class Engine {
quit: () => this.onExit(),
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
openMenu: () => this.openGameMenu(),
screensOn: (layer) => this.renderer.screensOn(layer),
rollback: () => this.rollback(),
openUrl: (url) => {
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
@@ -1134,11 +1262,25 @@ export class Engine {
if (!family) {
family = `pg-scr-${this.screenFonts.size}`;
this.screenFonts.set(file, family);
this.loadFont(family, file);
const ranges = fontGroups.get(file);
if (ranges) this.loadFontGroup(family, ranges);
else this.loadFont(family, file);
}
return family;
}
/** A font group is one family made of several files, each for its own range of letters (the first range that has a letter wins). */
private loadFontGroup(family: string, ranges: { file: string; first: number; last: number }[]) {
const hex = (n: number) => n.toString(16).toUpperCase();
// a later face of a family wins where ranges overlap, so the first range goes last
for (const { file, first, last } of [...ranges].reverse()) {
const url = this.fileUrl(file);
if (!url) continue;
const face = new FontFace(family, `url("${url}")`, { unicodeRange: `U+${hex(first)}-${hex(Math.max(first, last))}` });
face.load().then((f) => document.fonts.add(f)).catch(() => {});
}
}
private resetStore() {
this.screens.clear();
this.game.images = { ...this.originalImages };
@@ -1146,11 +1288,122 @@ export class Engine {
this.imagesByTag.clear();
for (const name of Object.keys(this.game.images)) this.indexImage(name);
this.store = {};
this.config.layers = [...DEFAULT_LAYERS];
this.addedLayers = [];
try {
this.py.exec(this.game.init);
} catch (err) {
console.warn("[pgvn] ошибка init", err);
}
this.syncLayers();
this.syncMouse();
}
private mouseEl: HTMLElement | null = null;
private mouseTrack: ((ev: PointerEvent) => void) | null = null;
/** `config.mouse_displayable` (a picture that follows the pointer) and `config.mouse` (cursor pictures). */
private syncMouse() {
const stage = this.renderer.stage;
if (this.mouseTrack) stage.removeEventListener("pointermove", this.mouseTrack);
this.mouseTrack = null;
this.mouseEl?.remove();
this.mouseEl = null;
stage.style.cursor = "";
const follower = this.config.mouse_displayable;
const cursor = this.config.mouse?.default?.[0];
if (follower && typeof follower === "object" && "__mouse" in follower) {
const picture = this.screens.element(follower.__mouse);
if (picture) {
const box = el("div", "pg-mouse");
box.style.cssText = "position:absolute;left:0;top:0;pointer-events:none;z-index:1000;display:none";
picture.style.position = "absolute";
picture.style.left = `${follower.xoffset ?? 0}px`;
picture.style.top = `${follower.yoffset ?? 0}px`;
box.append(picture);
stage.append(box);
this.mouseEl = box;
stage.style.cursor = "none";
this.mouseTrack = (ev) => {
const rect = stage.getBoundingClientRect();
const scale = rect.width / this.renderer.width || 1;
box.style.display = "";
box.style.transform = `translate(${(ev.clientX - rect.left) / scale}px, ${(ev.clientY - rect.top) / scale}px)`;
};
stage.addEventListener("pointermove", this.mouseTrack);
stage.addEventListener("pointerleave", () => { box.style.display = "none"; });
}
} else if (Array.isArray(cursor)) {
const url = this.fileUrl(pyStr(cursor[0]));
if (url) stage.style.cursor = `url("${url}") ${Number(cursor[1]) || 0} ${Number(cursor[2]) || 0}, auto`;
}
}
/** The game's own text tags (config.custom_text_tags), links, inline pictures and fonts for the text renderer. */
private installTextHooks() {
const dict = (value: any) => (value instanceof PyDict ? value : null);
const find = (name: string) => dict(this.config.custom_text_tags)?.get(name) ?? dict(this.config.self_closing_custom_text_tags)?.get(name);
textHooks.has = (name) => typeof find(name) === "function";
textHooks.selfClosing = (name) => typeof dict(this.config.self_closing_custom_text_tags)?.get(name) === "function";
textHooks.call = (name, argument, contents) => {
const fn = find(name);
if (typeof fn !== "function") return null;
try {
// a tag without a closing tag is given no text
const answer = textHooks.selfClosing?.(name) ? fn(name, argument) : fn(name, argument, contents);
return Array.isArray(answer) ? answer : null;
} catch (err) {
console.warn(`[pgvn] текстовый тег {${name}}`, err);
return null;
}
};
textHooks.displayable = (value) => this.screens.element(value);
textHooks.image = (name) => this.screenImage(name);
textHooks.font = (file) => this.screenFont(file);
textHooks.link = (target) => this.followLink(target);
}
/** `{a=target}`: the game's config.hyperlink_handler, or Ren'Py's own (web addresses, show:, hide:, jump:, call_in_new_context:). */
private followLink(target: string) {
const handler = this.config.hyperlink_handler;
if (typeof handler === "function") {
try { handler(target); } catch (err) { console.warn("[pgvn] hyperlink_handler", err); }
return;
}
if (/^https?:\/\//i.test(target)) window.open(target, "_blank", "noopener,noreferrer");
else if (target.startsWith("show:")) this.screens.show(target.slice(5).split(/\s+/)[0] ?? "");
else if (target.startsWith("hide:")) this.screens.hide(target.slice(5).trim());
else if (target.startsWith("jump:")) this.restart(() => this.guardedJump(target.slice(5).trim()));
else if (target.startsWith("call_in_new_context:") || target.startsWith("call:")) this.restart(() => this.call(target.slice(target.indexOf(":") + 1).trim()));
}
private layerNames(): string[] {
const names = this.config.layers;
return Array.isArray(names) ? names.map(String) : [...DEFAULT_LAYERS];
}
private syncLayers() {
this.renderer.setLayers(this.layerNames());
}
/** `renpy.add_layer`: the new layer goes above or below another, or at the top. */
private addLayer(name: string, above: any, below: any) {
const names = this.layerNames();
if (!names.includes(name)) {
const a = above ? names.indexOf(pyStr(above)) : -1;
const b = below ? names.indexOf(pyStr(below)) : -1;
names.splice(a >= 0 ? a + 1 : b >= 0 ? b : names.length, 0, name);
this.config.layers = names;
}
this.addedLayers.push({ name, above: above ? pyStr(above) : null, below: below ? pyStr(below) : null });
this.syncLayers();
}
/** The transforms of an at_list as the renderer takes them. */
private atFromList(list: any): AtSpec[] {
return ((Array.isArray(list) ? list : []) as any[]).filter((t) => t && t.__transform).map((t) => ({
name: t.__transform, ...(this.game.atl?.[t.__transform] ? { atl: t.__transform } : {}),
...(t.args?.length ? { args: t.args.map((v: any) => ({ k: "const", v })) } : {}) }));
}
private newStore() {
@@ -1399,10 +1652,10 @@ export class Engine {
case "hide": {
if (node.with) this.renderer.commit();
if (node.op === "scene") {
this.renderer.scene();
this.clearNvl();
this.renderer.scene(node.layer);
if (!node.layer || node.layer === "master") this.clearNvl();
}
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag);
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag, node.layer);
else if (node.img) this.showNode(node);
if (node.with) await this.renderer.transition(this.transition(node.with));
this.advance();
@@ -1461,7 +1714,7 @@ export class Engine {
const { args, kw } = this.screenArgs(node);
this.interactionStart();
this.setSkip(false);
const value = await this.wait<any>((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done }));
const value = await this.wait<any>((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done, layer: node.layer }));
this.screens.hideCall(node.name);
this.store._return = value ?? null;
this.advance();
@@ -1469,7 +1722,7 @@ export class Engine {
}
case "show_screen": {
const { args, kw } = this.screenArgs(node);
this.screens.show(node.name, args, kw);
this.screens.show(node.name, args, kw, { layer: node.layer });
this.advance();
return;
}
@@ -1478,7 +1731,7 @@ export class Engine {
this.advance();
return;
case "camera":
this.renderer.camera(node.at ?? []);
this.renderer.layerAt(node.layer ?? "master", node.at ?? []);
this.advance();
return;
case "return":
@@ -1518,7 +1771,7 @@ export class Engine {
const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]);
for (let n = 1; n <= img.name.length; n++) this.seenImages.add(img.name.slice(0, n).join(" "));
const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined;
this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind });
this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind, layer: node.layer });
}
private transition(t: any) {
@@ -1630,13 +1883,14 @@ export class Engine {
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0;
if (mode === "extend" && this.lastSay) {
prefixLen = [...plainText(parseText(this.lastSay.text))].length;
prefixLen = parseText(this.lastSay.text, false).length;
raw = this.lastSay.text + raw;
}
const parsed = parseText(raw);
this.lastSay = { name, text: raw, color: char.color, char };
if (mode !== "extend") this.history.push({ name, text: plainText(parsed) });
else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed);
const shownText = plainText(parseText(raw, false));
if (mode !== "extend") this.history.push({ name, text: shownText });
else if (this.history.length) this.history[this.history.length - 1].text = shownText;
if (this.history.length > 300) this.history.shift();
let target: HTMLElement;
@@ -1682,9 +1936,18 @@ export class Engine {
this.ctc.remove();
const total = parsed.length;
const waits = waitPoints(parsed).filter((w) => w.at > from);
const speeds = cpsPoints(parsed);
let shown = from;
const cps = this.settings.cps;
textSettings.cps = cps; // {fi} letters fade in at the text speed
// `{cps=30}` sets the speed from there on, `{cps=*2}` doubles the player's, `{/cps}` goes back to it
const speedAt = (position: number) => {
let speed = cps;
for (const point of speeds) {
if (point.at > position) break;
speed = point.value === null ? cps : point.relative ? cps * point.value : point.value;
}
return speed > 0 ? speed : cps;
};
if (instant || cps <= 0 || this.skip) {
renderText(target, parsed, total, this.baseSize);
target.append(this.ctc);
@@ -1701,7 +1964,7 @@ export class Engine {
while (shown < stop && !completed && !this.skip) {
await this.frame();
const now = performance.now();
shown = Math.min(stop, shown + ((now - last) / 1000) * cps);
shown = Math.min(stop, shown + ((now - last) / 1000) * speedAt(Math.floor(shown)));
last = now;
renderText(target, parsed, Math.floor(shown), this.baseSize);
}
@@ -1797,7 +2060,7 @@ export class Engine {
});
this.menuEl.classList.add("pg-hidden");
if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text);
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(this.tr.str(node.items[index].text))))}` });
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(this.tr.str(node.items[index].text)), false))}` });
this.enter(index);
}
@@ -1855,10 +2118,10 @@ export class Engine {
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
this.renderer.scene(n.layer);
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
else if (n.op === "hide") this.renderer.hide(n.tag, n.layer);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
@@ -1891,8 +2154,7 @@ export class Engine {
private newGame(at?: Frame) {
this.restart(() => {
this.audio.stopAll();
this.renderer.scene();
this.renderer.camera([]);
this.renderer.reset();
this.renderer.commit();
this.newStore();
this.stack = [];
@@ -1930,7 +2192,9 @@ export class Engine {
music: this.audio.current("music"),
window: this.windowShown,
camera: this.renderer.cameraAt,
text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "",
layers: this.renderer.layerAts(),
addedLayers: this.addedLayers.map((l) => ({ ...l })),
text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text, false))}` : "",
};
}
@@ -2004,17 +2268,19 @@ export class Engine {
const saved = decodeValue(data.store);
this.py.restoreNamespaces(saved);
Object.assign(this.store, saved);
for (const l of data.addedLayers ?? []) this.addLayer(l.name, l.above, l.below);
this.renderer.reset();
this.screens.restore(decodeValue(data.screens ?? []));
this.renderer.scene();
for (const s of data.scene) {
const img = this.resolveImage(s.name);
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder, layer: s.layer });
}
this.renderer.commit();
this.audio.stopAll();
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
this.windowShown = data.window;
this.renderer.camera(data.camera ?? []);
for (const [layer, at] of Object.entries(data.layers ?? {})) this.renderer.layerAt(layer, at);
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
this.cur = data.cur;
this.lastSay = null;
+15 -1
View File
@@ -345,6 +345,7 @@ export function pyStr(v: any): string {
if (v === true) return "True";
if (v === false) return "False";
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
if (v && v.__renpyColor === true) return v.hexcode;
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
if (v instanceof PyInstance) return instanceText(v);
if (v instanceof PyDict)
@@ -1166,10 +1167,23 @@ export class Python {
return current;
}
/** `default phone.data = ...`: assign to a name inside a store. */
/** `default phone.data = ...`: assign to a name inside a store, or to an attribute of an object the player provides (`default preferences.x`). */
assignPath(path: string, value: any) {
const at = path.lastIndexOf(".");
if (at < 0) return void this.scope.set(path, value);
const parts = path.split(".");
const known = this.scope.has(parts[0]) ? this.scope.get(parts[0]) : null;
if (known && typeof known === "object" && !(known instanceof PyNamespace)) {
let target: any = known;
for (const part of parts.slice(1, -1)) {
const next = target instanceof PyInstance ? target.attrs[part] : target[part];
if (next && typeof next === "object") target = next;
else return void this.namespaceOf(path.slice(0, at)).vars.set(path.slice(at + 1), value);
}
if (target instanceof PyInstance) target.attrs[parts[parts.length - 1]] = value;
else target[parts[parts.length - 1]] = value;
return;
}
this.namespaceOf(path.slice(0, at)).vars.set(path.slice(at + 1), value);
}
+50 -14
View File
@@ -9,8 +9,7 @@
// return rv
//
// A Render here is a tree of placed pieces (other renders, ordinary displayables, filled rectangles and drawing
// commands) that the host turns into DOM elements; there is no software rasteriser. Shaders and GPU properties are
// accepted and ignored: they only change how a picture is drawn.
// commands) that the host turns into DOM elements; there is no software rasteriser.
import { OBJECT, PyClass, PyInstance, nativeMethod, registerNativeClass, truthy, type Python } from "./python";
@@ -21,6 +20,8 @@ interface Piece {
x: number;
y: number;
render?: Render;
/** The part of the child render that shows, in the child's own coordinates (`Render.subsurface`). */
clip?: [number, number, number, number];
leaf?: any;
size?: [number, number];
fill?: string;
@@ -36,11 +37,14 @@ export interface Render {
fill: (color: any, rect?: any) => null;
canvas: () => Record<string, any>;
get_size: () => [number, number];
subsurface: (rect: any, focus?: any) => Render;
/** `Render.zoom(xzoom, yzoom)`: the whole render is drawn this much larger when it is blitted. */
scale?: [number, number];
add_shader: (...a: any[]) => null;
add_uniform: (...a: any[]) => null;
add_property: (...a: any[]) => null;
add_focus: (...a: any[]) => null;
zoom: (...a: any[]) => null;
zoom: (xzoom: any, yzoom?: any) => null;
__render: true;
}
@@ -77,7 +81,14 @@ export function makeRender(width: number, height: number): Render {
return { circle: add("circle"), rect: add("rect"), line: add("line"), lines: add("polygon"), polygon: add("polygon") };
},
get_size: () => [render.width, render.height],
add_shader: () => null, add_uniform: () => null, add_property: () => null, add_focus: () => null, zoom: () => null,
subsurface: (rect) => {
const [x, y, w, h] = (Array.isArray(rect) ? rect : [0, 0, render.width, render.height]).map(Number);
const part = makeRender(w, h);
part.pieces.push({ x: -x, y: -y, render, clip: [x, y, w, h] });
return part;
},
add_shader: () => null, add_uniform: () => null, add_property: () => null, add_focus: () => null,
zoom: (xzoom: any, yzoom: any = xzoom) => { render.scale = [Number(xzoom), Number(yzoom)]; return null; },
__render: true,
};
return render;
@@ -140,7 +151,9 @@ export interface RenderHostOptions {
/** Puts a game's own displayable on the page and keeps it drawn for as long as it is there. */
export class RenderHost {
private cache = new Map<any, HTMLElement>();
/** Elements drawn for leaves of render trees: objects are remembered only while something refers to them. */
private cache = new WeakMap<object, { element: HTMLElement; rev: number }>();
private named = new Map<string, HTMLElement>();
private redraw = new WeakMap<object, number>();
private measuring: HTMLElement | null = null;
@@ -193,9 +206,15 @@ export class RenderHost {
const started = performance.now();
const canvases: HTMLCanvasElement[] = [];
let next = 0;
let attached = performance.now();
const paint = () => {
if (!root.isConnected && next > 0) return;
const now = performance.now();
if (root.isConnected) attached = now;
else if (next > 0) {
// taken off the page for a moment (its text is being laid out again): look again, give up after a while
if (now - attached < 3000) requestAnimationFrame(paint);
return;
}
const [w, h] = this.options.area();
const st = (now - started) / 1000;
let tree: Render | null = null;
@@ -245,14 +264,28 @@ export class RenderHost {
root.style.height = `${tree.height}px`;
const children: HTMLElement[] = [];
let used = 0;
const walk = (render: Render, ox: number, oy: number) => {
const walk = (render: Render, ox: number, oy: number, sink: HTMLElement[] = children) => {
for (const piece of render.pieces) {
const x = ox + piece.x, y = oy + piece.y;
if (piece.render) { walk(piece.render, x, y); continue; }
if (piece.render) {
const child = piece.render;
if (!piece.clip && !child.scale) { walk(child, x, y, sink); continue; }
// a part of a render, or one that was zoomed: it is drawn inside a box of its own
const [cx, cy, cw, ch] = piece.clip ?? [0, 0, child.width, child.height];
const [zx, zy] = child.scale ?? [1, 1];
const box = document.createElement("div");
box.style.cssText = `position:absolute;left:${x + cx * zx}px;top:${y + cy * zy}px;width:${cw}px;height:${ch}px;`
+ `overflow:hidden;transform-origin:0 0;transform:${zx === 1 && zy === 1 ? "none" : `scale(${zx}, ${zy})`}`;
const inner: HTMLElement[] = [];
walk(child, -cx, -cy, inner);
box.append(...inner);
sink.push(box);
continue;
}
if (piece.fill) {
const box = document.createElement("div");
box.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${piece.size![0]}px;height:${piece.size![1]}px;background:${piece.fill}`;
children.push(box);
sink.push(box);
} else if (piece.commands) {
const canvas = (canvases[used] ??= document.createElement("canvas"));
used++;
@@ -260,19 +293,22 @@ export class RenderHost {
canvas.height = tree.height;
canvas.style.cssText = `position:absolute;left:${ox}px;top:${oy}px;pointer-events:none`;
this.paintCommands(canvas, piece.commands);
children.push(canvas);
sink.push(canvas);
} else if (piece.leaf !== undefined) {
const key = typeof piece.leaf === "object" && piece.leaf !== null ? piece.leaf : `s:${String(piece.leaf)}`;
let element = this.cache.get(key);
const byObject = typeof piece.leaf === "object" && piece.leaf !== null;
const name = `s:${String(piece.leaf)}`;
const rev = byObject ? (piece.leaf.rev ?? 0) : 0;
const kept = byObject ? this.cache.get(piece.leaf) : undefined;
let element = byObject ? (kept && kept.rev === rev ? kept.element : undefined) : this.named.get(name);
if (!element) {
element = this.options.element(piece.leaf) ?? undefined;
if (element) this.cache.set(key, element);
if (element) { if (byObject) this.cache.set(piece.leaf, { element, rev }); else this.named.set(name, element); }
}
if (!element) continue;
element.style.position = "absolute";
element.style.left = `${x}px`;
element.style.top = `${y}px`;
children.push(element);
sink.push(element);
}
}
};
+154 -30
View File
@@ -45,13 +45,27 @@ interface Entry extends ShownImage {
runners: { runner: AtlRunner; block: AtlBlock }[];
}
/** One of the game's layers (config.layers): its own pictures, its own transform (camera) and, if screens are shown on it, their container. */
interface Layer {
name: string;
el: HTMLDivElement;
shown: Map<string, Entry>;
at: AtSpec[];
runners: Entry["runners"];
ui: HTMLDivElement | null;
}
export class Renderer {
readonly stage: HTMLDivElement;
readonly master: HTMLDivElement;
private overlay: HTMLDivElement;
private shown = new Map<string, Entry>();
private layers = new Map<string, Layer>();
/** config.layers: bottom to top. The "screens" layer is the player's own interface (text box, menus, screens). */
private order = ["master", "transient", "screens", "overlay"];
private committed: HTMLElement | null = null;
fast = false;
/** `preferences.transitions = 0`: scene changes happen at once. */
transitionsOff = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) {
@@ -64,9 +78,82 @@ export class Renderer {
this.overlay = document.createElement("div");
this.overlay.className = "pg-layer pg-transition";
this.stage.append(this.master, this.overlay);
this.layers.set("master", { name: "master", el: this.master, shown: new Map(), at: [], runners: [], ui: null });
this.master.dataset.layer = "master";
this.arrange();
parent.append(this.stage);
}
// --- layers --------------------------------------------------------------
private layer(name = "master"): Layer {
let layer = this.layers.get(name);
if (!layer) {
const el = document.createElement("div");
el.className = "pg-layer";
el.dataset.layer = name;
this.stage.insertBefore(el, this.overlay);
layer = { name, el, shown: new Map(), at: [], runners: [], ui: null };
this.layers.set(name, layer);
this.arrange();
}
return layer;
}
/** Stacking: layers below "screens" go under the interface, the others over it; the order is config.layers'. */
private depth(name: string): number {
const screens = this.order.includes("screens") ? this.order.indexOf("screens") : this.order.length;
const index = this.order.indexOf(name);
return ((index < 0 ? this.order.length : index) - screens) * 10;
}
private arrange() {
let below = -10;
for (const layer of this.layers.values()) {
const z = this.depth(layer.name);
layer.el.style.zIndex = String(z);
if (z < 0) below = Math.max(below, z);
}
this.overlay.style.zIndex = String(below + 5); // transitions play over the layers under the interface
}
/** `config.layers` changed (or a layer was added). */
setLayers(names: string[]) {
this.order = names.map(String);
for (const name of this.order) if (name !== "screens") this.layer(name);
this.arrange();
}
/** Where screens shown on a layer go: a container inside the layer (the interface layer has the player's own). */
screensOn(name: string): HTMLElement | null {
if (name === "screens") return null;
const layer = this.layer(name);
if (!layer.ui) {
layer.ui = document.createElement("div");
layer.ui.className = "pg-usr";
layer.el.append(layer.ui);
}
return layer.ui;
}
/** `show_layer_at` / `camera`: the transform of a whole layer, animated like a picture's. */
layerAt(name: string, at: AtSpec[]) {
const layer = this.layer(name);
layer.runners.forEach((r) => r.runner.stop());
layer.runners = [];
layer.at = at;
const p: Record<string, any> = {};
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
this.style(layer.el, { ...p });
this.animate({ el: layer.el, runners: layer.runners }, p, at);
}
layerAts(): Record<string, AtSpec[]> {
const out: Record<string, AtSpec[]> = {};
for (const layer of this.layers.values()) if (layer.at.length) out[layer.name] = layer.at;
return out;
}
fit(container: HTMLElement) {
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
@@ -74,16 +161,28 @@ export class Renderer {
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) {
/** `scene`: nothing stays on the layer. */
scene(layerName = "master") {
const layer = this.layers.get(layerName);
if (!layer) return;
for (const e of layer.shown.values()) {
e.runners.forEach((r) => r.runner.stop());
e.el.remove();
}
this.shown.clear();
layer.shown.clear();
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag);
/** Every layer back to empty and untransformed: a new game, a loaded save. */
reset() {
for (const layer of this.layers.values()) {
this.scene(layer.name);
this.layerAt(layer.name, []);
}
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[]; layer?: string } = {}) {
const layer = this.layer(opts.layer);
const prev = layer.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? [];
this.panWanted = this.usesPan(img.def, atSpecs);
const el = this.build(img.def);
@@ -96,11 +195,11 @@ export class Renderer {
if (prev) {
prev.el.replaceWith(el);
} else {
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
this.master.insertBefore(el, behind ?? later ?? null);
const behind = opts.behind?.map((t) => layer.shown.get(t)?.el).find(Boolean);
const later = [...layer.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
layer.el.insertBefore(el, behind ?? later ?? layer.ui);
}
this.shown.set(tag, entry);
layer.shown.set(tag, entry);
this.style(el, { ...base }); // now that it is in the page it has a size
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
}
@@ -141,7 +240,7 @@ export class Renderer {
}
/** Start the ATL timelines of an image; each works on its own copy of the properties. */
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
private animate(entry: Pick<Entry, "el" | "runners">, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
if (this.fast) return;
const live = { ...base };
for (const spec of specs) {
@@ -151,10 +250,11 @@ export class Renderer {
if (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners);
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
this.shown.delete(tag);
hide(tag: string, layerName = "master") {
const layer = this.layers.get(layerName);
const e = layer?.shown.get(tag);
if (!layer || !e) return;
layer.shown.delete(tag);
const exit = e.runners.find((r) => r.runner.hasHideHandler(r.block));
if (!exit || this.fast) {
e.runners.forEach((r) => r.runner.stop());
@@ -166,16 +266,22 @@ export class Renderer {
void exit.runner.hide(exit.block).finally(() => e.el.remove());
}
isShown(tag: string) {
return this.shown.has(tag);
isShown(tag: string, layer = "master") {
return this.layers.get(layer)?.shown.has(tag) ?? false;
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null;
attrsOf(tag: string, layer = "master"): string[] | null {
return this.layers.get(layer)?.shown.get(tag)?.name ?? null;
}
snapshot(): ShownImage[] {
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
const out: ShownImage[] = [];
for (const layer of this.layers.values()) {
for (const { tag, name, at, zorder } of layer.shown.values()) {
out.push({ tag, name, at, zorder, ...(layer.name === "master" ? {} : { layer: layer.name }) });
}
}
return out;
}
// --- displayables ------------------------------------------------------------
@@ -416,15 +522,12 @@ export class Renderer {
}
camera(at: AtSpec[]) {
const p: Record<string, any> = {};
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
const zoom = p.zoom ?? 1;
this.master.style.transformOrigin = "50% 50%";
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
this.cameraAt = at;
this.layerAt("master", at);
}
cameraAt: AtSpec[] = [];
get cameraAt(): AtSpec[] {
return this.layers.get("master")?.at ?? [];
}
/** Sets the element's position and look; returns the properties it was built from. */
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]): Record<string, any> {
@@ -480,7 +583,15 @@ export class Renderer {
return;
}
el.style.visibility = "";
const out = layoutTransform(p, size[0], size[1], this.width, this.height);
const isLayer = el.classList.contains("pg-layer");
const z11 = isLayer ? 0 : (el.parentElement as any)?.__z11 ?? 0;
const out = layoutTransform(p, size[0], size[1], this.width, this.height, z11);
if (isLayer) { // a perspective camera: the pictures on the layer sit at depths (zpos) around the 1:1 plane
const depth = p.perspective === true ? 1000 : typeof p.perspective === "number" ? p.perspective
: Array.isArray(p.perspective) ? Number(p.perspective[1]) || 0 : 0;
(el as any).__z11 = depth;
el.style.perspective = depth ? `${depth}px` : "";
}
el.style.left = `${out.left}px`;
el.style.top = `${out.top}px`;
el.style.transformOrigin = "0 0";
@@ -499,13 +610,26 @@ export class Renderer {
/** Remember the current picture as the starting point of the next transition. */
commit() {
this.committed = this.master.cloneNode(true) as HTMLElement;
this.committed = this.picture();
}
/** A copy of what the layers under the interface show now (the screens on them are not copied: they do not take part). */
private picture(): HTMLElement {
const box = document.createElement("div");
box.className = "pg-layer";
for (const layer of this.layers.values()) {
if (this.depth(layer.name) > 0) continue;
const copy = layer.el.cloneNode(true) as HTMLElement;
copy.querySelector(":scope > .pg-usr")?.remove();
box.append(copy);
}
return box;
}
async transition(t: Transition | null | undefined): Promise<void> {
const before = this.committed;
this.commit();
if (!t || !before || this.fast) return;
if (!t || !before || this.fast || this.transitionsOff) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
+58 -17
View File
@@ -2,6 +2,7 @@
// whenever game state may have changed. Expressions run in the same Python sandbox as the script.
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import { cssColor, makeColor } from "./colors";
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
import type { AudioSystem } from "./audio";
import { isNativeAction } from "./musicroom";
@@ -43,6 +44,8 @@ export interface ScreenHost {
quit(): void;
mainMenu(): void;
openMenu(): void;
/** The container for screens shown on a layer of the game (config.layers); null for the interface layer. */
screensOn?(layer: string): HTMLElement | null;
/** The Rollback action: back to the previous interaction. */
rollback?(): void;
openUrl(url: string): void;
@@ -107,7 +110,18 @@ const legacyMatrix = (...given: any[]) => {
};
export class Displayable {
/** Counts the changes made after it was built (`set_text`), so a picture drawn from it is drawn again. */
rev = 0;
constructor(public kind: string, public args: any[], public kw: Props) {}
/** `Text.set_text`: the displayable shows other text from now on. */
set_text(text: any) {
if (this.kind === "text") {
this.args[0] = text;
this.rev++;
}
return null;
}
}
export class BarValue {
@@ -118,6 +132,8 @@ export class BarValue {
interface Instance {
name: string;
/** The layer it is shown on: "screens" unless the script said otherwise. */
layer: string;
def: ScreenDef;
args: any[];
kw: Props;
@@ -250,25 +266,33 @@ export class ScreenManager {
const previous = this.suspended;
this.suspended = true;
this.layer.style.visibility = "hidden";
return () => { this.suspended = previous; this.layer.style.visibility = previous ? "hidden" : ""; };
for (const inst of this.shown.values()) inst.el.style.visibility = "hidden";
return () => {
this.suspended = previous;
this.layer.style.visibility = previous ? "hidden" : "";
for (const inst of this.shown.values()) inst.el.style.visibility = previous ? "hidden" : "";
};
}
// --- lifecycle -------------------------------------------------------------------
show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) {
show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void; layer?: string } = {}) {
const def = this.screens[name];
if (!def) throw new Error(`Не найден screen ${name}`);
const previous = this.shown.get(name);
if (previous) this.dispose(previous, false);
const own = def.props.layer;
const layer = opts.layer ?? previous?.layer ?? (own?.k === "const" ? pyStr(own.v) : "screens");
const inst: Instance = {
name, def, args, kw, frame: new Map(), vars: previous?.vars ?? new Map(), order: previous?.order ?? ++this.counter,
name, layer, def, args, kw, frame: new Map(), vars: previous?.vars ?? new Map(), order: previous?.order ?? ++this.counter,
onReturn: opts.onReturn, call: !!opts.call, el: document.createElement("div"), timers: [], keys: [], runners: [],
clocks: new Map(), clocksSeen: new Set(), finished: previous?.finished ?? new Map(),
shownEvents: previous?.shownEvents ?? new Set(), hideActions: [],
};
inst.el.className = "pg-sinst";
this.shown.set(name, inst);
this.layer.append(inst.el);
(this.host.screensOn?.(layer) ?? this.layer).append(inst.el);
if (this.suspended) inst.el.style.visibility = "hidden";
this.bindParameters(inst);
if (!previous) this.runInside(inst, () => {
for (const [variable, expr] of def.defaults) inst.vars.set(variable, this.host.py.eval(expr));
@@ -311,14 +335,14 @@ export class ScreenManager {
this.layer.remove();
}
snapshot(): { name: string; args: any[]; kw: Props }[] {
snapshot(): { name: string; args: any[]; kw: Props; layer?: string }[] {
return [...this.shown.values()].filter((i) => !i.call).sort((a, b) => a.order - b.order)
.map((i) => ({ name: i.name, args: i.args, kw: i.kw }));
.map((i) => ({ name: i.name, args: i.args, kw: i.kw, ...(i.layer === "screens" ? {} : { layer: i.layer }) }));
}
restore(list: { name: string; args: any[]; kw: Props }[]) {
restore(list: { name: string; args: any[]; kw: Props; layer?: string }[]) {
this.clear();
for (const item of list) if (this.screens[item.name]) this.show(item.name, item.args, item.kw);
for (const item of list) if (this.screens[item.name]) this.show(item.name, item.args, item.kw, { layer: item.layer });
}
private dispose(inst: Instance, forget: boolean) {
@@ -634,7 +658,10 @@ export class ScreenManager {
state === "selected_hover" ? ["selected_hover", "selected", "hover", ""] : ["selected_insensitive", "insensitive", ""];
for (const prefix of order) {
const key = prefix ? `${prefix}_${name}` : name;
if (key in props) return this.ev(props[key]);
if (key in props) {
const value = this.ev(props[key]);
return value && value.__renpyColor === true ? value.hexcode : value; // a Color is a CSS colour once it is drawn
}
}
return undefined;
}
@@ -955,6 +982,7 @@ export class ScreenManager {
if (!pictures.length) return;
const overlay = document.createElement("div");
overlay.className = "pg-gallery";
overlay.style.pointerEvents = "auto";
Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "9999", background: "black", display: "grid" });
const image = document.createElement("div");
Object.assign(image.style, { display: "grid", gridArea: "1 / 1", placeItems: "center" });
@@ -1056,6 +1084,11 @@ export class ScreenManager {
}
/** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */
/** The element a displayable value stands for (the player's mouse picture). */
element(value: any): HTMLElement | null {
return this.displayable(value);
}
private displayable(value: any, depth = 0): HTMLElement | null {
if (depth > 8 || value === null || value === undefined) return null;
if (isDisplayableInstance(value)) return this.renderHost.host(value);
@@ -1097,7 +1130,8 @@ export class ScreenManager {
const box = document.createElement("div");
const size = num(value.kw.size) ?? this.host.baseSize;
this.host.text(box, pyStr(a), size);
if (typeof value.kw.color === "string") box.style.color = value.kw.color;
const textColor = cssColor(value.kw.color);
if (textColor) box.style.color = textColor;
return box;
}
case "image": return this.displayable(a, depth + 1);
@@ -1233,7 +1267,7 @@ export class ScreenManager {
return box;
}
case "transform": {
const child = this.displayable(a, depth + 1);
const child = this.displayable(a ?? value.kw.child, depth + 1);
if (!child) return null;
const p = value.kw;
const parts: string[] = [];
@@ -1455,8 +1489,9 @@ export class ScreenManager {
const size = num(this.pick(props, "size", state));
if (size !== null) el.style.fontSize = `${size}px`;
const font = this.pick(props, "font", state);
if (typeof font === "string") {
const family = this.host.fontFamily(font);
const fontName = typeof font === "string" ? font : typeof font?.toString === "function" && String(font).startsWith("@group:") ? String(font) : null;
if (fontName) {
const family = this.host.fontFamily(fontName);
if (family) el.style.fontFamily = `"${family}"`;
}
if (truthy(this.pick(props, "bold", state))) el.style.fontWeight = "bold";
@@ -2012,7 +2047,7 @@ export class ScreenManager {
out.Null = shape("null");
out.Transform = shape("transform");
out.DynamicDisplayable = shape("dynamic");
out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value));
out.Color = (...a: any[]) => makeColor(a);
// Character(...) built by game code: kept as a record the say statement can read
out.Character = (...args: any[]) => {
const last = args[args.length - 1];
@@ -2028,7 +2063,7 @@ export class ScreenManager {
out.RoundedCorners = rounded;
out.CurriedRoundedCorners = rounded;
out.ExplodeText = (child: any) => child; // the letter-explosion effect is shown as the plain text
out.MouseDisplayable = () => null; // a custom mouse picture: the browser keeps its own cursor
out.MouseDisplayable = (child: any, xoffset = 0, yoffset = 0) => ({ __mouse: child, xoffset, yoffset });
out.ConditionSwitch = shape("conditionswitch");
out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText`
out.MatrixColor = shape("matrixcolor");
@@ -2105,11 +2140,17 @@ export class ScreenManager {
const last = a[a.length - 1];
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
const args = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? a.slice(1, -1) : a.slice(1);
this.show(String(a[0]), args, kw);
const { _layer, ...rest } = kw;
this.show(String(a[0]), args, rest, { layer: _layer ? pyStr(_layer) : undefined });
return null;
},
hide_screen: (name: string) => { this.hide(String(name)); return null; },
get_screen: (name: string) => (this.shown.has(name) ? true : null),
get_screen: (name: string, ...more: any[]) => {
const last = more[more.length - 1];
const layer = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw.layer : more[0];
const found = this.shown.get(name);
return found && (!layer || found.layer === pyStr(layer)) ? true : null;
},
restart_interaction: () => { this.refresh(); return null; },
};
}
+255 -72
View File
@@ -1,7 +1,6 @@
// Ren'Py text tags → styled segments, plus [variable] interpolation.
import { pyStr } from "./python";
import * as fx from "./texteffects";
export interface Style {
b?: boolean;
@@ -10,11 +9,18 @@ export interface Style {
s?: boolean;
color?: string;
size?: string;
/** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */
fx?: string;
fxArg?: string;
/** One id per opening tag, so the letters of one tag are counted together. */
fxId?: number;
/** `{font=file}`, `{alpha=0.5}`, `{k=2}` (kerning), `{outlinecolor=#fff}` */
font?: string;
alpha?: string;
k?: string;
outline?: string;
/** `{a=target}`: where a click on the text goes. */
link?: string;
/** `{rb}` the base text of a ruby and `{rt}` its annotation. */
rb?: boolean;
rt?: boolean;
/** `{alt}`: read to the player's screen reader, not shown. */
hidden?: boolean;
/** The tag that opened this style, so its closing tag finds it again. */
tag?: string;
}
@@ -22,7 +28,14 @@ export interface Style {
export type Segment =
| { kind: "text"; text: string; style: Style }
| { kind: "wait"; seconds: number | null } // {w}, {w=1}, {p}
| { kind: "fast" };
| { kind: "fast" }
/** `{cps=30}` / `{cps=*2}`; `value: null` is the closing tag (back to the player's speed). */
| { kind: "cps"; value: number | null; relative: boolean }
/** A displayable a custom text tag produced (`renpy.TEXT_DISPLAYABLE`), one character wide. */
| { kind: "display"; value: any; style: Style }
| { kind: "image"; name: string; style: Style }
/** `{space=20}` and `{vspace=20}` */
| { kind: "gap"; width: number; height: number };
export interface ParsedText {
segments: Segment[];
@@ -30,6 +43,25 @@ export interface ParsedText {
length: number; // number of visible characters
}
/** What the player lends the text: the game's own tags (config.custom_text_tags) and the elements for what they return. */
export interface TextHooks {
/** Whether the game registered a text tag of this name, and whether it has no closing tag. */
has?(name: string): boolean;
selfClosing?(name: string): boolean;
/** Run the tag's function: contents are (kind, text) pairs, the answer is the same kind of list. */
call?(name: string, argument: string, contents: [number, string][]): [number, any][] | null;
displayable?(value: any): HTMLElement | null;
image?(name: string): HTMLElement | null;
font?(file: string): string | null;
/** A click on `{a=target}` text. */
link?(target: string): void;
}
export const textHooks: TextHooks = {};
/** Kinds of the (kind, text) pairs of custom text tags (renpy.TEXT_*). */
export const TEXT_TEXT = 1, TEXT_TAG = 2, TEXT_PARAGRAPH = 3, TEXT_DISPLAYABLE = 4;
export function interpolate(text: string, lookup: (expr: string) => any,
translate: (s: string) => string = (s) => s): string {
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
@@ -50,17 +82,74 @@ export function interpolate(text: string, lookup: (expr: string) => any,
});
}
let fxCounter = 0;
const displayed = new Map<number, any>();
let displayedCount = 0;
export function parseText(text: string): ParsedText {
/** The (kind, text) list a custom text tag's function receives for the text between its tags. */
function tokens(inner: string): [number, string][] {
const out: [number, string][] = [];
let buf = "";
for (let i = 0; i < inner.length;) {
if (inner[i] === "{") {
if (inner[i + 1] === "{") { buf += "{"; i += 2; continue; }
const end = inner.indexOf("}", i);
if (end >= 0) {
if (buf) out.push([TEXT_TEXT, buf]);
buf = "";
out.push([TEXT_TAG, inner.slice(i + 1, end)]);
i = end + 1;
continue;
}
}
buf += inner[i++];
}
if (buf) out.push([TEXT_TEXT, buf]);
return out;
}
/** What a custom tag's function answered, as text with tags again (a displayable becomes a marker the parser knows). */
function untokens(list: [number, any][]): string {
let out = "";
for (const [kind, value] of list) {
if (kind === TEXT_TEXT) out += pyStr(value).replace(/\{/g, "{{");
else if (kind === TEXT_TAG) out += `{${pyStr(value)}}`;
else if (kind === TEXT_PARAGRAPH) out += "\n";
else if (kind === TEXT_DISPLAYABLE) {
displayed.set(++displayedCount, value);
out += `{pgdisp=${displayedCount}}`;
}
}
return out;
}
/** Where the text of the custom tag opened at `from` (just after its `}`) ends: the matching closing tag, or the end. */
function closing(text: string, name: string, from: number): [number, number] {
const pattern = new RegExp(`\\{(/?)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:=[^}]*)?\\}`, "g");
pattern.lastIndex = from;
let depth = 1;
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
depth += m[1] ? -1 : 1;
if (depth === 0) return [m.index, m.index + m[0].length];
}
return [text.length, text.length];
}
/**
* `expand` runs the game's own text tags (they are Python functions that may create displayables); without it their
* text is simply shown, which is what history, saves and the translator want.
*/
export function parseText(text: string, expand = true): ParsedText {
const segments: Segment[] = [];
const stack: Style[] = [{}];
let noWait = false;
let length = 0;
let expansions = 0;
const push = (t: string) => {
if (!t) return;
const top = stack[stack.length - 1];
if (top.hidden) return;
length += [...t].length;
segments.push({ kind: "text", text: t, style: { ...stack[stack.length - 1] } });
segments.push({ kind: "text", text: t, style: { ...top } });
};
let i = 0;
let buf = "";
@@ -80,15 +169,31 @@ export function parseText(text: string): ParsedText {
push(buf);
buf = "";
const tag = text.slice(i + 1, end);
i = end + 1;
const [name, value] = splitTag(tag);
const top = stack[stack.length - 1];
if (expand && name && !name.startsWith("/") && textHooks.has?.(name) && expansions++ < 500) {
// the game's own tag: its function turns the text inside into what is shown instead
let inner = "";
let after = end + 1;
if (!textHooks.selfClosing?.(name)) {
const [stop, next] = closing(text, name, end + 1);
inner = text.slice(end + 1, stop);
after = next;
}
const answer = textHooks.call?.(name, value, tokens(inner));
if (answer) {
text = text.slice(0, i) + untokens(answer) + text.slice(after);
continue;
}
}
i = end + 1;
if (name.startsWith("/")) {
const opened = name.slice(1);
let at = stack.length - 1;
if (opened) while (at > 0 && stack[at].tag !== opened) at--;
if (opened && at > 0) stack.length = at; // closes what it opened, and anything left open inside
else if (!opened && stack.length > 1) stack.pop(); // `{/}` closes the innermost style
if (opened === "cps" && at > 0) segments.push({ kind: "cps", value: null, relative: false });
continue;
}
switch (name) {
@@ -98,14 +203,60 @@ export function parseText(text: string): ParsedText {
case "s":
stack.push({ ...top, [name]: true, tag: name });
break;
case "plain":
stack.push({ ...top, b: false, i: false, u: false, s: false, tag: name });
break;
case "color":
stack.push({ ...top, color: value, tag: name });
break;
case "size":
stack.push({ ...top, size: value, tag: name });
break;
case "bt": case "sc": case "fi": case "rotat": case "glitch": case "wave": // letters that move (text tags of the games)
stack.push({ ...top, fx: name, fxArg: value, fxId: ++fxCounter, tag: name });
case "font":
stack.push({ ...top, font: value, tag: name });
break;
case "alpha":
stack.push({ ...top, alpha: value, tag: name });
break;
case "k":
stack.push({ ...top, k: value, tag: name });
break;
case "outlinecolor":
stack.push({ ...top, outline: value, tag: name });
break;
case "a":
stack.push({ ...top, link: value, u: top.u ?? true, tag: name });
break;
case "rb":
stack.push({ ...top, rb: true, tag: name });
break;
case "rt":
stack.push({ ...top, rt: true, tag: name });
break;
case "alt":
stack.push({ ...top, hidden: true, tag: name });
break;
case "noalt":
stack.push({ ...top, tag: name });
break;
case "cps": {
const relative = value.startsWith("*");
const n = Number(relative ? value.slice(1) : value);
segments.push({ kind: "cps", value: Number.isFinite(n) ? n : null, relative });
stack.push({ ...top, tag: name });
break;
}
case "pgdisp":
if (!top.hidden) {
length += 1;
segments.push({ kind: "display", value: displayed.get(Number(value)), style: { ...top } });
}
break;
case "image":
if (!top.hidden) {
length += 1;
segments.push({ kind: "image", name: value, style: { ...top } });
}
break;
case "w":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
@@ -121,22 +272,14 @@ export function parseText(text: string): ParsedText {
segments.push({ kind: "fast" });
break;
case "space":
push(" ");
segments.push({ kind: "gap", width: Number(value) || 0, height: 0 });
break;
case "vspace":
push("\n");
break;
case "explode": case "explodehalf": case "gradient": case "gradient2": case "fadegrad": case "fadegradfi":
case "ruby": case "note": case "chaos": case "swap": case "move": case "omega": // shown as plain text
stack.push({ ...top, tag: name });
break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
stack.push({ ...top, tag: name }); // styling we ignore but must still balance
segments.push({ kind: "gap", width: 0, height: Number(value) || 0 });
break;
default:
// a tag with its closing tag later ({chaos}...{/chaos}) shows its text plainly; anything else
// ({image=...}, {#comment}, {done}, {clear}) is self-closing
// a tag of the game that shows its text as it is ({chaos}...{/chaos} without the game's function), or with a
// closing tag later; anything else ({#comment}, {done}, {clear}) is self-closing
if (name && text.includes(`{/${name}}`, end)) stack.push({ ...top, tag: name });
break;
}
@@ -154,69 +297,92 @@ function splitTag(tag: string): [string, string] {
return eq < 0 ? [tag.trim(), ""] : [tag.slice(0, eq).trim(), tag.slice(eq + 1).trim()];
}
/** Dress a piece of text (or any inline element) the way the tags around it say. */
function decorate(piece: HTMLElement, st: Style, baseSize: number) {
if (st.b) piece.style.fontWeight = "bold";
if (st.i) piece.style.fontStyle = "italic";
if (st.u || st.s) piece.style.textDecoration = [st.u && "underline", st.s && "line-through"].filter(Boolean).join(" ");
if (st.color) piece.style.color = st.color;
if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize);
if (st.font) {
const family = textHooks.font?.(st.font);
if (family) piece.style.fontFamily = `"${family}"`;
}
if (st.alpha) piece.style.opacity = String(alphaValue(st.alpha));
if (st.k) piece.style.letterSpacing = `${Number(st.k) || 0}px`;
if (st.outline) {
piece.style.setProperty("-webkit-text-stroke", `2px ${st.outline}`);
piece.style.paintOrder = "stroke fill";
}
if (st.link) {
piece.style.cursor = "pointer";
piece.style.pointerEvents = "auto";
piece.addEventListener("click", (ev) => { ev.stopPropagation(); textHooks.link?.(st.link!); });
}
}
const plain = (st: Style) => !st.b && !st.i && !st.u && !st.s && !st.color && !st.size && !st.font && !st.alpha && !st.k && !st.outline && !st.link;
/** How many visible characters a segment has. */
const width = (seg: Segment) => (seg.kind === "text" ? [...seg.text].length : seg.kind === "display" || seg.kind === "image" ? 1 : 0);
/** Render the first `visible` characters of parsed text into `el`. */
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
el.textContent = "";
const counters = new Map<number, number>();
let left = visible;
for (const seg of parsed.segments) {
if (seg.kind === "gap") {
const gap = document.createElement("span");
if (seg.width) gap.style.cssText = `display:inline-block;width:${seg.width}px`;
else gap.style.cssText = `display:block;height:${seg.height}px`;
el.append(gap);
continue;
}
if (seg.kind === "display" || seg.kind === "image") {
if (left <= 0) break;
left -= 1;
const inner = seg.kind === "display" ? textHooks.displayable?.(seg.value) : textHooks.image?.(seg.name);
if (inner) {
const box = document.createElement("span");
box.className = "pg-inline";
box.style.cssText = "display:inline-block;vertical-align:baseline";
box.append(inner);
decorate(box, seg.style, baseSize);
el.append(box);
}
continue;
}
if (seg.kind !== "text") continue;
if (left <= 0) break;
const chars = [...seg.text];
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
left -= chars.length;
const st = seg.style;
if (st.fx) {
fx.markStart(el);
const decorate = (piece: HTMLElement) => {
if (st.b) piece.style.fontWeight = "bold";
if (st.i) piece.style.fontStyle = "italic";
if (st.color) piece.style.color = st.color;
if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize);
};
if (st.fx === "glitch") { // the effect acts on the whole run of text
const run = document.createElement("span");
run.className = "pg-fx";
run.textContent = shown;
decorate(run);
el.append(run);
fx.glitch(run, st.fxArg ?? "");
if (st.rt) { // the annotation of the ruby that the `{rb}` before it began
const ruby = el.lastElementChild;
if (ruby instanceof HTMLElement && ruby.tagName === "RUBY") {
const note = document.createElement("rt");
note.textContent = shown;
ruby.append(note);
continue;
}
for (const letter of [...shown]) {
const piece = document.createElement("span");
piece.textContent = letter;
piece.className = "pg-fx";
decorate(piece);
el.append(piece);
const id = st.fxId ?? 0;
const arg = st.fxArg ?? "";
if (st.fx === "bt") {
const n = counters.get(id) ?? 0;
counters.set(id, n + 1);
fx.bounce(piece, arg, n);
} else if (st.fx === "fi") {
if (letter === " ") continue; // blanks are not counted, nor faded
const first = fx.split(arg)[0];
const n = counters.get(id) ?? (first ? parseInt(first, 10) : 0);
counters.set(id, n + 1);
fx.fadeIn(piece, arg, n);
} else if (st.fx === "sc") fx.scare(piece, arg);
else if (st.fx === "rotat") fx.rotation(piece, arg);
}
}
if (st.rb) {
const ruby = document.createElement("ruby");
const base = document.createElement("span");
base.textContent = shown;
decorate(base, st, baseSize);
ruby.append(base);
el.append(ruby);
continue;
}
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
if (plain(st)) {
el.append(shown);
continue;
}
const span = document.createElement("span");
span.textContent = shown;
if (st.b) span.style.fontWeight = "bold";
if (st.i) span.style.fontStyle = "italic";
if (st.u || st.s) span.style.textDecoration = [st.u && "underline", st.s && "line-through"].filter(Boolean).join(" ");
if (st.color) span.style.color = st.color;
if (st.size) span.style.fontSize = sizeValue(st.size, baseSize);
decorate(span, st, baseSize);
el.append(span);
}
}
@@ -227,13 +393,30 @@ function sizeValue(v: string, base: number): string {
return `${Number(v)}px`;
}
function alphaValue(v: string): number {
const n = Number(v.startsWith("+") || v.startsWith("*") ? v.slice(1) : v);
if (!Number.isFinite(n)) return 1;
return Math.max(0, Math.min(1, v.startsWith("+") ? 1 + n : n));
}
/** Character offsets at which {w}/{p} pauses happen, with their durations. */
export function waitPoints(parsed: ParsedText): { at: number; seconds: number | null }[] {
const out: { at: number; seconds: number | null }[] = [];
let pos = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "wait") out.push({ at: pos, seconds: seg.seconds });
pos += width(seg);
if (seg.kind === "wait") out.push({ at: pos, seconds: seg.seconds });
}
return out;
}
/** The speed changes `{cps=...}` makes: from character `at` on, typing runs at `value` (null: the player's own speed). */
export function cpsPoints(parsed: ParsedText): { at: number; value: number | null; relative: boolean }[] {
const out: { at: number; value: number | null; relative: boolean }[] = [];
let pos = 0;
for (const seg of parsed.segments) {
pos += width(seg);
if (seg.kind === "cps") out.push({ at: pos, value: seg.value, relative: seg.relative });
}
return out;
}
@@ -242,8 +425,8 @@ export function fastPoint(parsed: ParsedText): number {
let pos = 0;
let fast = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "fast") fast = pos;
pos += width(seg);
if (seg.kind === "fast") fast = pos;
}
return fast;
}
-127
View File
@@ -1,127 +0,0 @@
// Per-letter text effects of the Kinetic Text Tags library and its relatives ({bt} {fi} {sc} {rotat} {glitch}), with
// the formulas of the library's own displayables (BounceText, FadeInText, ScareText, GlitchText). Each letter is an
// element that a single animation loop moves for as long as the element stays on the page.
/** Text speed in characters per second (0 = instant), as the player's settings have it. */
export const textSettings = { cps: 0 };
interface Motion {
el: HTMLElement;
update(t: number): void;
}
const live = new Set<Motion>();
let ticking = false;
function tick() {
const now = performance.now();
for (const motion of [...live]) {
if (!motion.el.isConnected) { live.delete(motion); continue; }
const start = Number(motion.el.closest<HTMLElement>("[data-fx-start]")?.dataset.fxStart ?? now);
motion.update((now - start) / 1000);
}
if (live.size) requestAnimationFrame(tick);
else ticking = false;
}
function add(motion: Motion) {
live.add(motion);
motion.update(0);
if (!ticking) { ticking = true; requestAnimationFrame(tick); }
}
/** The time zero of the effects inside `root`: set once, so a text that is typed out letter by letter keeps its phase. */
export function markStart(root: HTMLElement) {
if (!root.dataset.fxStart) root.dataset.fxStart = String(performance.now());
}
export const split = (arg: string) => arg.split("-").filter((p) => p !== "");
/** `{bt=a20-p2.0-s0.5}` or `{bt=12}`: amplitude, period and speed of the wave. */
export function bounce(el: HTMLElement, arg: string, offset: number) {
let amp = 20, period = 4.0, speed = 1.0;
if (arg !== "") {
const parts = split(arg);
if (parts.length === 1 && /^\d/.test(parts[0])) amp = parseInt(parts[0], 10);
else for (const part of parts) {
if (part[0] === "a") amp = parseInt(part.slice(1), 10);
else if (part[0] === "p") period = parseFloat(part.slice(1));
else if (part[0] === "s") speed = parseFloat(part.slice(1));
}
}
add({ el, update: (t) => { el.style.transform = `translateY(${Math.sin(period * (t * speed + offset * -0.1)) * amp}px)`; } });
}
/** `{fi=index-time-distance}`: each letter fades in and slides, one after another at the text speed. */
export function fadeIn(el: HTMLElement, arg: string, number: number) {
let fade = 5.0, slide = 100;
const parts = split(arg);
if (parts.length > 1) fade = parseFloat(parts[1]);
if (parts.length > 2) slide = parseInt(parts[2], 10);
const delay = textSettings.cps !== 0 ? number / textSettings.cps : 0;
add({ el, update: (t) => {
let alpha = 0, x = 5.0;
if (t > delay) {
const progress = (t - delay) / fade;
alpha = Math.min(1, progress);
x = Math.max(slide - progress * slide, 0);
}
el.style.opacity = String(alpha);
el.style.transform = `translateX(${x}px)`;
} });
}
/** `{sc=5}`: every letter jitters inside a square of that size, on every frame. */
export function scare(el: HTMLElement, arg: string) {
const square = arg === "" ? 5 : parseFloat(arg);
add({ el, update: () => { el.style.transform = `translate(${(Math.random() - 0.5) * square}px, ${(Math.random() - 0.5) * square}px)`; } });
}
/** `{rotat=300}`: the letter turns about its own centre at that many degrees a second. */
export function rotation(el: HTMLElement, arg: string) {
const speed = arg === "" ? 300 : parseFloat(arg);
add({ el, update: (t) => { el.style.transform = `rotate(${t * speed}deg)`; } });
}
/** `{glitch=10}`: the text is cut into strips of random height and, now and then, a strip jumps sideways. */
export function glitch(el: HTMLElement, arg: string) {
const amount = arg === "" ? 10 : parseFloat(arg);
const pool: HTMLElement[] = [];
el.style.position = "relative";
let color = "";
let text = "";
add({ el, update: () => {
const h = el.offsetHeight, w = el.offsetWidth;
if (!h || !w) return;
if (!color) { // the text itself is hidden; the strips, which carry its colour, are what shows
text = el.textContent ?? "";
color = getComputedStyle(el).color;
el.style.color = "transparent";
}
let used = 0;
let y = 0;
let guard = 0;
while (y < h && guard++ < 200) {
const occurs = Math.random() * 100 < amount;
const height = occurs ? Math.round(Math.random() * 20 - 10) : Math.round(Math.random() * 10);
const offset = Math.round(Math.random() * 20 - 10);
if (height > 0) {
let strip = pool[used];
if (!strip) {
strip = document.createElement("span");
strip.className = "pg-glitch-strip";
strip.style.cssText = `position:absolute;left:0;top:0;white-space:pre;pointer-events:none;color:${color}`;
strip.textContent = text;
el.append(strip);
pool.push(strip);
}
used++;
strip.style.visibility = "visible";
strip.style.clipPath = `inset(${y}px 0 ${Math.max(0, h - y - height)}px 0)`;
strip.style.transform = `translateX(${occurs ? offset : 0}px)`;
}
y += Math.abs(height) || 1;
}
for (let i = used; i < pool.length; i++) pool[i].style.visibility = "hidden";
} });
}
+4
View File
@@ -181,6 +181,10 @@ export interface SaveData {
music: { file: string; loop: boolean } | null;
window: boolean;
camera?: AtSpec[];
/** Transforms of the other layers (`show_layer_at`, `camera <layer>`). */
layers?: Record<string, AtSpec[]>;
/** Layers added by `renpy.add_layer`, in order. */
addedLayers?: { name: string; above: string | null; below: string | null }[];
menuSeen?: string[];
text?: string;
screens?: { name: string; args: any[]; kw: Record<string, any> }[];
+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");
});