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