From a7034b3563ac83b721ccf83331c0fdf9915951cc Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:39:48 +0300 Subject: [PATCH] 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 --- frontend/src/player/colors.ts | 180 ++++++ frontend/src/player/engine.ts | 400 ++++++++++--- frontend/src/player/player.css | 756 ++++++++++++------------ frontend/src/player/python.ts | 16 +- frontend/src/player/render.ts | 64 +- frontend/src/player/renderer.ts | 184 +++++- frontend/src/player/screens.ts | 75 ++- frontend/src/player/text.ts | 327 +++++++--- frontend/src/player/texteffects.ts | 127 ---- frontend/src/player/types.ts | 4 + frontend/tests/engine-behavior.test.mjs | 80 +++ frontend/tests/texttags.test.mjs | 52 ++ frontend/tests/ui.test.mjs | 8 +- 13 files changed, 1563 insertions(+), 710 deletions(-) create mode 100644 frontend/src/player/colors.ts delete mode 100644 frontend/src/player/texteffects.ts create mode 100644 frontend/tests/texttags.test.mjs diff --git a/frontend/src/player/colors.ts b/frontend/src/player/colors.ts new file mode 100644 index 0000000..71dc2ca --- /dev/null +++ b/frontend/src/player/colors.ts @@ -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 { + 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 = { + 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 = 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; } +} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 98d3215..0cf96ef 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -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(); + 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(); @@ -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 = {}; + 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 = {}; + private muted = new Map(); + + /** 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 { 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 = { + 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 = { + 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): Promise { + 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 = {}; + 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 { 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((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done })); + const value = await this.wait((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; diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index bb725d4..056ce35 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -1,378 +1,378 @@ -.pg-root { - position: relative; - width: 100%; - height: 100%; - background: #000; - overflow: hidden; - user-select: none; -} - -.pg-stage { - --pg-accent: #ff9f43; - --pg-text: #ffffff; - position: absolute; - left: 50%; - top: 50%; - overflow: hidden; - background: #000; - color: var(--pg-text); - font-family: "Segoe UI", system-ui, sans-serif; - font-size: var(--pg-font); - transform-origin: center; - cursor: default; -} -.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; } -.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; } -.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; } - -.pg-layer { position: absolute; inset: 0; } -.pg-transition { pointer-events: none; } -.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; } -.pg-fill { position: absolute; inset: 0; } -.pg-image { position: absolute; will-change: transform, opacity; } -.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; } - -.pg-hidden { display: none !important; } - -.pg-textbox { - position: absolute; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92)); - border-top: 2px solid rgba(255, 255, 255, 0.12); -} -.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; } -.pg-textbox.pg-ui-hidden { opacity: 0; } - -.pg-namebox { - position: absolute; - top: 0; - transform: translateY(-50%); - padding: 0.15em 0.8em; - font-size: var(--pg-name-font); - font-weight: 600; - color: var(--pg-accent); - background: rgba(10, 12, 24, 0.85); - border-radius: 0.3em; - text-shadow: 0 1px 2px #000; -} -.pg-textbox-image .pg-namebox { background: transparent; } - -.pg-text { - position: absolute; - line-height: 1.35; - white-space: pre-wrap; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); -} -.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; } -@keyframes pg-blink { 50% { opacity: 0.1; } } - -.pg-quick { - position: absolute; - bottom: 0.35em; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 0.15em; - padding: 0.1em 0.3em; - border-radius: 99em; - background: rgba(8, 10, 20, 0.45); - backdrop-filter: blur(6px); - opacity: 0.55; - transition: opacity 0.2s; -} -.pg-quick:hover, .pg-quick:focus-within { opacity: 1; } -.pg-quick button { - background: none; - border: 0; - border-radius: 99em; - color: rgba(255, 255, 255, 0.8); - font: inherit; - font-size: calc(var(--pg-font) * 0.55); - padding: 0.25em 0.75em; - cursor: pointer; - transition: background 0.15s, color 0.15s; -} -.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } -.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); } -.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); } - -.pg-menu { - position: absolute; - inset: 0 0 26% 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5em; -} -.pg-choice { - min-width: 50%; - max-width: 75%; - padding: 0.45em 1.2em; - font: inherit; - color: #fff; - background: rgba(15, 18, 35, 0.85); - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 0.3em; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} -.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); } - -.pg-nvl { - position: absolute; - inset: 0; - padding: 5% 15%; - background: rgba(0, 0, 0, 0.72) center / cover; - display: flex; - flex-direction: column; - gap: 0.6em; - overflow: hidden; -} -.pg-nvl-name { font-weight: 600; margin-right: 0.6em; } -.pg-nvl-what { white-space: pre-wrap; } - -.pg-centered { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 0 15%; - white-space: pre-wrap; - text-shadow: 0 2px 4px #000; -} - -.pg-notify { - position: absolute; - top: 2%; - left: 2%; - padding: 0.4em 1em; - font-size: calc(var(--pg-font) * 0.7); - background: rgba(0, 0, 0, 0.8); - border-left: 4px solid var(--pg-accent); -} - -.pg-screen { - position: absolute; - inset: 0; - background: rgba(8, 10, 20, 0.88) center / cover; - backdrop-filter: blur(10px); - display: flex; - flex-direction: column; - padding: 3% 6%; - font-size: calc(var(--pg-font) * 0.85); - z-index: 10; - animation: pg-fade 0.15s ease-out; -} -@keyframes pg-fade { from { opacity: 0; } } -.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; } -.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; } -.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; } -.pg-screen button, .pg-dialog button, .pg-screen select { - font: inherit; - color: #fff; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 0.5em; - padding: 0.5em 1.1em; - cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s; -} -.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); } -.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible { - outline: 3px solid var(--pg-accent); - outline-offset: 2px; -} -.pg-screen button:disabled { opacity: 0.35; cursor: default; } -.pg-screen option { color: #111; } -.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; } -.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; } -.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); } -.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; } -.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; } -.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); } -.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; } -.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; } -.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; } -.pg-slot b { color: var(--pg-accent); font-weight: 600; } -.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; } -.pg-settings-screen { - --pg-accent: #ff9f43; - --pg-font: 18px; - z-index: 25; - padding: clamp(16px, 4%, 40px); - color: #edf0f7; - font: 16px/1.5 "Segoe UI", system-ui, sans-serif; - background: rgba(11, 14, 23, 0.96); - container-type: inline-size; - color-scheme: dark; -} -.pg-settings-screen .pg-screen-head, -.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; } -.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; } -.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; } -.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; } -.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; } -.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } -.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; } -.pg-settings-card:first-child { grid-row: span 2; } -.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } -.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; } -.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; } -.pg-setting:last-child { margin-bottom: 0; } -.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; } -.pg-setting input[type="range"] { - appearance: none; - -webkit-appearance: none; - display: block; - width: 100%; - min-height: 44px; - margin: 0; - padding: 0; - border: 0; - border-radius: 6px; - background: transparent; - box-shadow: none; - cursor: pointer; - accent-color: var(--pg-accent); -} -.pg-setting input[type="range"]::-webkit-slider-runnable-track { - height: 6px; - border-radius: 99px; - background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress)); -} -.pg-setting input[type="range"]::-webkit-slider-thumb { - appearance: none; - -webkit-appearance: none; - width: 20px; - height: 20px; - margin-top: -7px; - border: 3px solid #191e2a; - border-radius: 50%; - background: var(--pg-accent); - box-shadow: 0 0 0 1px var(--pg-accent); -} -.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; } -.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); } -.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); } -.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; } -.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; } -.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; } -@container (max-width: 620px) { - .pg-settings { grid-template-columns: minmax(0, 1fr); } - .pg-settings-card:first-child { grid-row: auto; } - .pg-settings-card { padding: 18px; } -} -.pg-wide { margin-top: 0.8em; margin-right: 0.6em; } - -.pg-screen.pg-main { background-color: #000; justify-content: center; } -.pg-main-panel { - display: flex; - flex-direction: column; - gap: 0.55em; - width: min(26em, 100%); - padding: 2em; - background: rgba(8, 10, 20, 0.72); - backdrop-filter: blur(14px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 1em; - box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5); -} -.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; } -.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; } -.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; } -.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); } - -/* menu button outside the scaled stage: stays tappable on phones */ -.pg-hud-btn { - position: absolute; - top: max(10px, env(safe-area-inset-top)); - right: max(10px, env(safe-area-inset-right)); - z-index: 20; - width: 44px; - height: 44px; - display: grid; - place-items: center; - font-size: 22px; - line-height: 1; - color: #fff; - background: rgba(8, 10, 20, 0.55); - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 50%; - cursor: pointer; - opacity: 0; - transition: opacity 0.2s, background 0.15s; -} -.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; } -.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); } -.pg-root.pg-in-main .pg-hud-btn { display: none; } -@media (pointer: coarse) { - .pg-hud-btn { opacity: 0.6; } -} - -/* in-player confirmation */ -.pg-dialog-back { - position: absolute; - inset: 0; - z-index: 30; - display: grid; - place-items: center; - padding: 16px; - background: rgba(5, 7, 12, 0.7); - backdrop-filter: blur(4px); - font-family: "Segoe UI", system-ui, sans-serif; -} -.pg-dialog { - width: min(420px, 100%); - padding: 22px; - color: #eceef5; - background: #171b25; - border: 1px solid #2a3042; - border-radius: 14px; - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); - font-size: 16px; -} -.pg-dialog p { margin: 0 0 18px; line-height: 1.45; } -.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; } -.pg-dialog button { font-size: 15px; padding: 9px 18px; } -.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; } - -/* --- game screens (Ren'Py screen language) ------------------------------------------ */ -.pg-usr { position: absolute; inset: 0; pointer-events: none; } -.pg-sinst { position: absolute; inset: 0; pointer-events: none; } -.pg-sinst.pg-smodal { pointer-events: auto; } -.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); } -.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; } -.pg-sbtn { cursor: pointer; user-select: none; } -.pg-sbtn.pg-sdisabled { cursor: default; } -.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; } -.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; } -.pg-stext { white-space: pre-wrap; } -.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; } -.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; } -.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; } -.pg-sadd { pointer-events: none; } -.pg-sviewport { scrollbar-width: thin; } - -/* --- renpy.input / renpy.display_menu ----------------------------------------------- */ -.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; } -.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; } -.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; } - -/* checkbox row of the settings screen */ -.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; } -.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); } - -/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */ -.pg-fx { display: inline-block; white-space: pre; } -/* SnowBlossom: pictures that fall and drift while the scene is shown */ -.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } -.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; } -@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } } +.pg-root { + position: relative; + width: 100%; + height: 100%; + background: #000; + overflow: hidden; + user-select: none; +} + +.pg-stage { + --pg-accent: #ff9f43; + --pg-text: #ffffff; + position: absolute; + left: 50%; + top: 50%; + overflow: hidden; + background: #000; + color: var(--pg-text); + font-family: "Segoe UI", system-ui, sans-serif; + font-size: var(--pg-font); + transform-origin: center; + cursor: default; +} +.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; } +.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; } +.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; } + +.pg-layer { position: absolute; inset: 0; } +.pg-transition { pointer-events: none; } +.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; } +.pg-fill { position: absolute; inset: 0; } +.pg-image { position: absolute; will-change: transform, opacity; } +.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; } + +.pg-hidden { display: none !important; } + +.pg-textbox { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92)); + border-top: 2px solid rgba(255, 255, 255, 0.12); +} +.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; } +.pg-textbox.pg-ui-hidden { opacity: 0; } + +.pg-namebox { + position: absolute; + top: 0; + transform: translateY(-50%); + padding: 0.15em 0.8em; + font-size: var(--pg-name-font); + font-weight: 600; + color: var(--pg-accent); + background: rgba(10, 12, 24, 0.85); + border-radius: 0.3em; + text-shadow: 0 1px 2px #000; +} +.pg-textbox-image .pg-namebox { background: transparent; } + +.pg-text { + position: absolute; + line-height: 1.35; + white-space: pre-wrap; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); +} +.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; } +@keyframes pg-blink { 50% { opacity: 0.1; } } + +.pg-quick { + position: absolute; + bottom: 0.35em; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.15em; + padding: 0.1em 0.3em; + border-radius: 99em; + background: rgba(8, 10, 20, 0.45); + backdrop-filter: blur(6px); + opacity: 0.55; + transition: opacity 0.2s; +} +.pg-quick:hover, .pg-quick:focus-within { opacity: 1; } +.pg-quick button { + background: none; + border: 0; + border-radius: 99em; + color: rgba(255, 255, 255, 0.8); + font: inherit; + font-size: calc(var(--pg-font) * 0.55); + padding: 0.25em 0.75em; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); } +.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); } + +.pg-menu { + position: absolute; + inset: 0 0 26% 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5em; +} +.pg-choice { + min-width: 50%; + max-width: 75%; + padding: 0.45em 1.2em; + font: inherit; + color: #fff; + background: rgba(15, 18, 35, 0.85); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0.3em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); } + +.pg-nvl { + position: absolute; + inset: 0; + padding: 5% 15%; + background: rgba(0, 0, 0, 0.72) center / cover; + display: flex; + flex-direction: column; + gap: 0.6em; + overflow: hidden; +} +.pg-nvl-name { font-weight: 600; margin-right: 0.6em; } +.pg-nvl-what { white-space: pre-wrap; } + +.pg-centered { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 15%; + white-space: pre-wrap; + text-shadow: 0 2px 4px #000; +} + +.pg-notify { + position: absolute; + top: 2%; + left: 2%; + padding: 0.4em 1em; + font-size: calc(var(--pg-font) * 0.7); + background: rgba(0, 0, 0, 0.8); + border-left: 4px solid var(--pg-accent); +} + +.pg-screen { + position: absolute; + inset: 0; + background: rgba(8, 10, 20, 0.88) center / cover; + backdrop-filter: blur(10px); + display: flex; + flex-direction: column; + padding: 3% 6%; + font-size: calc(var(--pg-font) * 0.85); + z-index: 10; + animation: pg-fade 0.15s ease-out; +} +@keyframes pg-fade { from { opacity: 0; } } +.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; } +.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; } +.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; } +.pg-screen button, .pg-dialog button, .pg-screen select { + font: inherit; + color: #fff; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 0.5em; + padding: 0.5em 1.1em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); } +.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible { + outline: 3px solid var(--pg-accent); + outline-offset: 2px; +} +.pg-screen button:disabled { opacity: 0.35; cursor: default; } +.pg-screen option { color: #111; } +.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; } +.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; } +.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); } +.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; } +.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; } +.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); } +.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; } +.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; } +.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; } +.pg-slot b { color: var(--pg-accent); font-weight: 600; } +.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; } +.pg-settings-screen { + --pg-accent: #ff9f43; + --pg-font: 18px; + z-index: 25; + padding: clamp(16px, 4%, 40px); + color: #edf0f7; + font: 16px/1.5 "Segoe UI", system-ui, sans-serif; + background: rgba(11, 14, 23, 0.96); + container-type: inline-size; + color-scheme: dark; +} +.pg-settings-screen .pg-screen-head, +.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; } +.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; } +.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; } +.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; } +.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; } +.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } +.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; } +.pg-settings-card:first-child { grid-row: span 2; } +.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } +.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; } +.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; } +.pg-setting:last-child { margin-bottom: 0; } +.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.pg-setting input[type="range"] { + appearance: none; + -webkit-appearance: none; + display: block; + width: 100%; + min-height: 44px; + margin: 0; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + box-shadow: none; + cursor: pointer; + accent-color: var(--pg-accent); +} +.pg-setting input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + border-radius: 99px; + background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress)); +} +.pg-setting input[type="range"]::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 20px; + height: 20px; + margin-top: -7px; + border: 3px solid #191e2a; + border-radius: 50%; + background: var(--pg-accent); + box-shadow: 0 0 0 1px var(--pg-accent); +} +.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; } +.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); } +.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); } +.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; } +.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; } +.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; } +@container (max-width: 620px) { + .pg-settings { grid-template-columns: minmax(0, 1fr); } + .pg-settings-card:first-child { grid-row: auto; } + .pg-settings-card { padding: 18px; } +} +.pg-wide { margin-top: 0.8em; margin-right: 0.6em; } + +.pg-screen.pg-main { background-color: #000; justify-content: center; } +.pg-main-panel { + display: flex; + flex-direction: column; + gap: 0.55em; + width: min(26em, 100%); + padding: 2em; + background: rgba(8, 10, 20, 0.72); + backdrop-filter: blur(14px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1em; + box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5); +} +.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; } +.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; } +.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; } +.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); } + +/* menu button outside the scaled stage: stays tappable on phones */ +.pg-hud-btn { + position: absolute; + top: max(10px, env(safe-area-inset-top)); + right: max(10px, env(safe-area-inset-right)); + z-index: 20; + width: 44px; + height: 44px; + display: grid; + place-items: center; + font-size: 22px; + line-height: 1; + color: #fff; + background: rgba(8, 10, 20, 0.55); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 50%; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.15s; +} +.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; } +.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); } +.pg-root.pg-in-main .pg-hud-btn { display: none; } +@media (pointer: coarse) { + .pg-hud-btn { opacity: 0.6; } +} + +/* in-player confirmation */ +.pg-dialog-back { + position: absolute; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 16px; + background: rgba(5, 7, 12, 0.7); + backdrop-filter: blur(4px); + font-family: "Segoe UI", system-ui, sans-serif; +} +.pg-dialog { + width: min(420px, 100%); + padding: 22px; + color: #eceef5; + background: #171b25; + border: 1px solid #2a3042; + border-radius: 14px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); + font-size: 16px; +} +.pg-dialog p { margin: 0 0 18px; line-height: 1.45; } +.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; } +.pg-dialog button { font-size: 15px; padding: 9px 18px; } +.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; } + +/* --- game screens (Ren'Py screen language) ------------------------------------------ */ +.pg-usr { position: absolute; inset: 0; pointer-events: none; } +.pg-sinst { position: absolute; inset: 0; pointer-events: none; } +.pg-sinst.pg-smodal { pointer-events: auto; } +.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); } +.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; } +.pg-sbtn { cursor: pointer; user-select: none; } +.pg-sbtn.pg-sdisabled { cursor: default; } +.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; } +.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; } +.pg-stext { white-space: pre-wrap; } +.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; } +.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; } +.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; } +.pg-sadd { pointer-events: none; } +.pg-sviewport { scrollbar-width: thin; } + +/* --- renpy.input / renpy.display_menu ----------------------------------------------- */ +.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; } +.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; } +.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; } + +/* checkbox row of the settings screen */ +.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; } +.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); } + +/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */ +.pg-fx { display: inline-block; white-space: pre; } +/* SnowBlossom: pictures that fall and drift while the scene is shown */ +.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; } +@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } } diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index b837a6d..d6f29a9 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -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); } diff --git a/frontend/src/player/render.ts b/frontend/src/player/render.ts index ebaacd8..f539674 100644 --- a/frontend/src/player/render.ts +++ b/frontend/src/player/render.ts @@ -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; 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(); + /** Elements drawn for leaves of render trees: objects are remembered only while something refers to them. */ + private cache = new WeakMap(); + private named = new Map(); private redraw = new WeakMap(); 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); } } }; diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index 24751e0..21a7b23 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -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; + at: AtSpec[]; + runners: Entry["runners"]; + ui: HTMLDivElement | null; +} + export class Renderer { readonly stage: HTMLDivElement; readonly master: HTMLDivElement; private overlay: HTMLDivElement; - private shown = new Map(); + private layers = new Map(); + /** 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 = {}; + 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 { + const out: Record = {}; + 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, specs: AtSpec[], timeline?: AtlBlock) { + private animate(entry: Pick, base: Record, 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 = {}; - 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 { @@ -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 { 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"); diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 7454663..3fe8e08 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -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; }, }; } diff --git a/frontend/src/player/text.ts b/frontend/src/player/text.ts index 872e6d0..880bd6e 100644 --- a/frontend/src/player/text.ts +++ b/frontend/src/player/text.ts @@ -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(); +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(); 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; } diff --git a/frontend/src/player/texteffects.ts b/frontend/src/player/texteffects.ts deleted file mode 100644 index b2822de..0000000 --- a/frontend/src/player/texteffects.ts +++ /dev/null @@ -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(); -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("[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"; - } }); -} diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index 4598c39..fef02d8 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -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 `). */ + layers?: Record; + /** 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 }[]; diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index 22644f2..dca36ef 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -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(); } +}); diff --git a/frontend/tests/texttags.test.mjs b/frontend/tests/texttags.test.mjs new file mode 100644 index 0000000..198a603 --- /dev/null +++ b/frontend/tests/texttags.test.mjs @@ -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"); +}); diff --git a/frontend/tests/ui.test.mjs b/frontend/tests/ui.test.mjs index f23d77f..1465395 100644 --- a/frontend/tests/ui.test.mjs +++ b/frontend/tests/ui.test.mjs @@ -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"); });