feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py
ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
finite repeat, time, function f(trans, st, at), calls of other transforms with
arguments, on hover/idle events for buttons. Animations stop when their element
leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped
Colour
- matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG
filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values
Python
- match statement, re/copy/time/datetime/defaultdict/functools.partial, more
str/dict methods, Character(...) created by code
- narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in
Python blocks become script nodes
- Audit follows reachability: functions and classes nothing refers to (custom
statement lexers, unused tools) no longer block an import
Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and
QSP planned). Tests for the new runtime pieces.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
62ece25902
commit
ea03cebf6a
21 files changed
+1264
-93
No files matched your search
@@ -1,5 +1,7 @@
|
||||
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
|
||||
|
||||
import { ColorMatrix } from "./colormatrix";
|
||||
|
||||
export type AtlProps = Record<string, any>;
|
||||
|
||||
/** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */
|
||||
@@ -20,7 +22,11 @@ export type AtlStep =
|
||||
| { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] }
|
||||
| { op: "block"; block: AtlBlock }
|
||||
| { op: "on"; events: string[]; block: AtlBlock }
|
||||
| { op: "use"; name: string };
|
||||
| { op: "use"; name: string; args?: any[]; kw?: Record<string, any> }
|
||||
| { op: "image"; img: any; with: any }
|
||||
| { op: "contains"; img: any }
|
||||
| { op: "function"; f: any }
|
||||
| { op: "time"; t: AtlValue<number> };
|
||||
|
||||
export interface AtlHost {
|
||||
/** Timeline of a named animated transform. */
|
||||
@@ -31,6 +37,10 @@ export interface AtlHost {
|
||||
apply(props: AtlProps): void;
|
||||
/** Evaluate a run-time value with the transform's parameters in scope. */
|
||||
eval?(expr: any, vars: Map<string, any>): any;
|
||||
/** `"other.png" with dissolve`: show another picture in the animated element. */
|
||||
image?(img: any, transition: any): void;
|
||||
/** `contains:`: add a child picture that runs its own timeline. */
|
||||
contains?(img: any): void;
|
||||
}
|
||||
|
||||
/** Bind `name(args)` of an `at` clause to the transform's parameters. `ev` evaluates in the caller's scope. */
|
||||
@@ -69,8 +79,13 @@ function nextFrame() {
|
||||
export class AtlRunner {
|
||||
private token = 0;
|
||||
|
||||
/** When set, the timeline stops by itself once this element has left the document. */
|
||||
watch: HTMLElement | null = null;
|
||||
private attached = false;
|
||||
|
||||
constructor(private host: AtlHost, private props: AtlProps, private vars: Map<string, any> = new Map()) {}
|
||||
|
||||
|
||||
/** Resolve `{ e: expr }` values (also inside tuples). */
|
||||
private val<T = any>(v: AtlValue<T> | undefined): any {
|
||||
if (Array.isArray(v)) return v.map((x) => this.val(x));
|
||||
@@ -138,17 +153,41 @@ export class AtlRunner {
|
||||
|
||||
private alive(token: number) {
|
||||
if (token !== this.token) throw new Stopped();
|
||||
if (this.watch) {
|
||||
if (this.watch.isConnected) this.attached = true;
|
||||
else if (this.attached) {
|
||||
this.token++;
|
||||
throw new Stopped();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver a button/interaction event (`on hover:` ...) to the handlers of `block`. */
|
||||
event(block: AtlBlock, name: string) {
|
||||
const handlers = this.handlers(block, new Set([name]));
|
||||
if (!handlers.length) return;
|
||||
const token = ++this.token;
|
||||
void (async () => {
|
||||
try {
|
||||
for (const handler of handlers) await this.run(handler, token, 0, name);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Stopped)) throw err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private apply() {
|
||||
this.host.apply(this.props);
|
||||
}
|
||||
|
||||
private blockStart = 0;
|
||||
|
||||
private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> {
|
||||
if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность");
|
||||
let loops = 0;
|
||||
for (;;) {
|
||||
const startedAt = performance.now();
|
||||
this.blockStart = startedAt;
|
||||
let waited = false;
|
||||
for (let i = 0; i < block.steps.length; i++) {
|
||||
this.alive(token);
|
||||
@@ -203,8 +242,18 @@ export class AtlRunner {
|
||||
return true;
|
||||
case "use": {
|
||||
const named = this.host.block(step.name);
|
||||
if (named) await this.run(named, token, depth + 1, event);
|
||||
else {
|
||||
if (named) {
|
||||
// `other(args)`: the callee runs with its own parameters, on the same properties
|
||||
const callerVars = this.vars;
|
||||
if (step.args || step.kw || named.params) {
|
||||
this.vars = bindArguments(named, step, (e) => (this.host.eval ? this.host.eval(e, callerVars) : null));
|
||||
}
|
||||
try {
|
||||
await this.run(named, token, depth + 1, event);
|
||||
} finally {
|
||||
this.vars = callerVars;
|
||||
}
|
||||
} else {
|
||||
const props = this.host.props(step.name);
|
||||
if (props) {
|
||||
Object.assign(this.props, props);
|
||||
@@ -213,11 +262,53 @@ export class AtlRunner {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "image":
|
||||
this.host.image?.(step.img, step.with);
|
||||
return false;
|
||||
case "contains":
|
||||
this.host.contains?.(step.img);
|
||||
return false;
|
||||
case "time": {
|
||||
const target = this.blockStart + this.seconds(step.t) * 1000;
|
||||
while (performance.now() < target) {
|
||||
await nextFrame();
|
||||
this.alive(token);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "function":
|
||||
await this.callFunction(step.f, token);
|
||||
return true;
|
||||
case "on":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `function f`: Python moves the element. f(trans, st, at) returns the delay until the next call, or None. */
|
||||
private async callFunction(expr: any, token: number) {
|
||||
const f = this.host.eval ? this.host.eval(expr, this.vars) : null;
|
||||
if (typeof f !== "function") return;
|
||||
const props = this.props;
|
||||
const trans = new Proxy({}, {
|
||||
get: (_t, key: string) => props[key] ?? this.current(key),
|
||||
set: (_t, key: string, value) => {
|
||||
props[key] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const started = performance.now();
|
||||
for (;;) {
|
||||
this.alive(token);
|
||||
const elapsed = (performance.now() - started) / 1000;
|
||||
const next = f(trans, elapsed, elapsed);
|
||||
this.apply();
|
||||
if (next === null || next === undefined) return;
|
||||
const delay = Number(next);
|
||||
if (!Number.isFinite(delay)) return;
|
||||
await this.sleep(Math.max(delay, 0.016), token);
|
||||
}
|
||||
}
|
||||
|
||||
private async sleep(seconds: number, token: number) {
|
||||
const end = performance.now() + seconds * 1000;
|
||||
while (performance.now() < end) {
|
||||
@@ -265,6 +356,10 @@ export class AtlRunner {
|
||||
|
||||
function mix(a: any, b: any, k: number): any {
|
||||
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k;
|
||||
if (a instanceof ColorMatrix && b instanceof ColorMatrix) {
|
||||
return new ColorMatrix(a.rows.map((row, i) => row.map((v, j) => v + (b.rows[i][j] - v) * k) as any));
|
||||
}
|
||||
if (b instanceof ColorMatrix && (a === undefined || a === null || a === 0)) return mix(ColorMatrix.identity(), b, k);
|
||||
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k));
|
||||
return k >= 1 ? b : a;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Colour matrices (Ren'Py `matrixcolor` and the older `im.matrix`) as 5x5 matrices over (r, g, b, a, 1),
|
||||
// applied to pictures through an SVG feColorMatrix filter.
|
||||
|
||||
export type Row = [number, number, number, number, number];
|
||||
|
||||
const LUMA: [number, number, number] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
export class ColorMatrix {
|
||||
/** Rows r, g, b, a and the constant row [0, 0, 0, 0, 1]. */
|
||||
constructor(public rows: Row[]) {}
|
||||
|
||||
static identity() {
|
||||
return new ColorMatrix([[1, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 1]]);
|
||||
}
|
||||
|
||||
/** `a * b` applies b first, then a. */
|
||||
times(other: ColorMatrix): ColorMatrix {
|
||||
const out: Row[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const row = [0, 0, 0, 0, 0] as Row;
|
||||
for (let j = 0; j < 5; j++) {
|
||||
let sum = 0;
|
||||
for (let k = 0; k < 5; k++) sum += this.rows[i][k] * other.rows[k][j];
|
||||
row[j] = sum;
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
return new ColorMatrix(out);
|
||||
}
|
||||
|
||||
/** Lets the Python sandbox multiply matrices with `*`. */
|
||||
__binop__(op: string, other: any) {
|
||||
if (op === "*" && other instanceof ColorMatrix) return this.times(other);
|
||||
throw new TypeError("Матрицы цвета можно только перемножать");
|
||||
}
|
||||
|
||||
/** The 20 numbers of an feColorMatrix. */
|
||||
values(): string {
|
||||
return this.rows.slice(0, 4).map((row) => row.map((v) => +v.toFixed(5)).join(" ")).join(" ");
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return undefined; // never part of a save
|
||||
}
|
||||
}
|
||||
|
||||
function diag(r: number, g: number, b: number, a = 1) {
|
||||
const m = ColorMatrix.identity();
|
||||
m.rows[0][0] = r;
|
||||
m.rows[1][1] = g;
|
||||
m.rows[2][2] = b;
|
||||
m.rows[3][3] = a;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** "#rgb", "#rrggbb", "#rrggbbaa" or a (r, g, b[, a]) tuple of 0-255 / 0-1 numbers -> [r, g, b, a] in 0..1. */
|
||||
export function parseColor(value: any): [number, number, number, number] {
|
||||
if (typeof value === "string") {
|
||||
let hex = value.replace("#", "");
|
||||
if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join("");
|
||||
const n = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return [n(0) || 0, n(2) || 0, n(4) || 0, hex.length >= 8 ? n(6) : 1];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const scale = value.some((v) => v > 1) ? 255 : 1;
|
||||
return [value[0] / scale, value[1] / scale, value[2] / scale, value.length > 3 ? value[3] / scale : 1];
|
||||
}
|
||||
return [1, 1, 1, 1];
|
||||
}
|
||||
|
||||
export const colorMatrices = {
|
||||
IdentityMatrix: () => ColorMatrix.identity(),
|
||||
TintMatrix: (color: any) => {
|
||||
const [r, g, b, a] = parseColor(color);
|
||||
return diag(r, g, b, a);
|
||||
},
|
||||
OpacityMatrix: (value = 1) => diag(1, 1, 1, value),
|
||||
BrightnessMatrix: (value = 0) => {
|
||||
const m = ColorMatrix.identity();
|
||||
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value;
|
||||
return m;
|
||||
},
|
||||
ContrastMatrix: (value = 1) => {
|
||||
const m = diag(value, value, value);
|
||||
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = 0.5 * (1 - value);
|
||||
return m;
|
||||
},
|
||||
SaturationMatrix: (value = 1, desat: [number, number, number] = LUMA) => {
|
||||
const m = ColorMatrix.identity();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) m.rows[i][j] = (1 - value) * desat[j] + (i === j ? value : 0);
|
||||
}
|
||||
return m;
|
||||
},
|
||||
InvertMatrix: (value = 1) => {
|
||||
const m = diag(1 - 2 * value, 1 - 2 * value, 1 - 2 * value);
|
||||
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value;
|
||||
return m;
|
||||
},
|
||||
HueMatrix: (degrees = 0) => {
|
||||
const a = (degrees * Math.PI) / 180;
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
const m = ColorMatrix.identity();
|
||||
m.rows[0] = [0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928, 0, 0];
|
||||
m.rows[1] = [0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.14, 0.072 - c * 0.072 - s * 0.283, 0, 0];
|
||||
m.rows[2] = [0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072, 0, 0];
|
||||
return m;
|
||||
},
|
||||
ColorizeMatrix: (black: any, white: any) => {
|
||||
const [br, bg, bb] = parseColor(black);
|
||||
const [wr, wg, wb] = parseColor(white);
|
||||
const m = ColorMatrix.identity();
|
||||
const channels: [number, number][] = [[br, wr], [bg, wg], [bb, wb]];
|
||||
channels.forEach(([lo, hi], i) => {
|
||||
m.rows[i] = [(hi - lo) * LUMA[0], (hi - lo) * LUMA[1], (hi - lo) * LUMA[2], 0, lo];
|
||||
});
|
||||
return m;
|
||||
},
|
||||
};
|
||||
|
||||
/** The older `im.matrix.*` names. */
|
||||
export const legacyMatrices = {
|
||||
identity: colorMatrices.IdentityMatrix,
|
||||
brightness: colorMatrices.BrightnessMatrix,
|
||||
contrast: colorMatrices.ContrastMatrix,
|
||||
saturation: colorMatrices.SaturationMatrix,
|
||||
invert: () => colorMatrices.InvertMatrix(1),
|
||||
opacity: colorMatrices.OpacityMatrix,
|
||||
hue_rotate: colorMatrices.HueMatrix,
|
||||
colorize: colorMatrices.ColorizeMatrix,
|
||||
tint: (r: number, g: number, b: number) => diag(r, g, b),
|
||||
};
|
||||
|
||||
let host: SVGSVGElement | null = null;
|
||||
const known = new Map<string, string>();
|
||||
|
||||
/** CSS `filter` value that applies the matrix (an SVG filter is created once per distinct matrix). */
|
||||
export function matrixFilter(matrix: ColorMatrix): string {
|
||||
const values = matrix.values();
|
||||
let id = known.get(values);
|
||||
if (!id) {
|
||||
id = `pgm-${known.size}`;
|
||||
known.set(values, id);
|
||||
if (!host) {
|
||||
host = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
host.setAttribute("width", "0");
|
||||
host.setAttribute("height", "0");
|
||||
host.style.position = "absolute";
|
||||
host.append(document.createElementNS("http://www.w3.org/2000/svg", "defs"));
|
||||
document.body.append(host);
|
||||
}
|
||||
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
|
||||
filter.id = id;
|
||||
filter.setAttribute("color-interpolation-filters", "sRGB");
|
||||
const fe = document.createElementNS("http://www.w3.org/2000/svg", "feColorMatrix");
|
||||
fe.setAttribute("type", "matrix");
|
||||
fe.setAttribute("values", values);
|
||||
filter.append(fe);
|
||||
host.querySelector("defs")!.append(filter);
|
||||
}
|
||||
return `url(#${id})`;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import { ScreenManager, type ScreenHost } from "./screens";
|
||||
import { pythonModules } from "./pymodules";
|
||||
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,
|
||||
@@ -321,6 +322,20 @@ export class Engine {
|
||||
...this.screens.renpyApi(),
|
||||
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
|
||||
end_replay: () => null,
|
||||
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
||||
loadable: (path: any) => this.fileUrl(String(path)) !== null,
|
||||
is_init_phase: () => false,
|
||||
call_stack_depth: () => this.stack.length,
|
||||
const: (value: any) => value,
|
||||
screenshot: () => null,
|
||||
Keymap: () => null,
|
||||
register_shader: () => null,
|
||||
register_statement: () => null,
|
||||
redraw: () => null,
|
||||
stop_predict: () => null,
|
||||
map_event: () => null,
|
||||
count_displayables_in_layer: () => 0,
|
||||
displayable: (value: any) => value,
|
||||
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
|
||||
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
|
||||
seen_image: (name: any) => this.seenImages.has(String(name)),
|
||||
@@ -351,6 +366,10 @@ export class Engine {
|
||||
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
||||
// a read-only view: `"name" in globals()` works, writes do not reach the game
|
||||
globals: () => new PyDict(Object.entries(this.store)),
|
||||
print: () => null,
|
||||
...pythonModules(),
|
||||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||||
_window_show: () => { this.windowShown = true; return null; },
|
||||
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
|
||||
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
|
||||
str: (x: any) => pyStr(x),
|
||||
@@ -429,6 +448,8 @@ export class Engine {
|
||||
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
|
||||
quickSave: () => void this.save("quick"),
|
||||
quickLoad: () => void this.load("quick"),
|
||||
transform: (name) => this.game.transforms[name] ?? null,
|
||||
atl: (name) => this.game.atl?.[name] ?? null,
|
||||
};
|
||||
@@ -729,7 +750,7 @@ export class Engine {
|
||||
this.renderer.scene();
|
||||
this.clearNvl();
|
||||
}
|
||||
if (node.op === "hide") this.renderer.hide(node.tag);
|
||||
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag);
|
||||
else if (node.img) this.showNode(node);
|
||||
if (node.with) await this.renderer.transition(this.transition(node.with));
|
||||
this.advance();
|
||||
@@ -878,7 +899,11 @@ export class Engine {
|
||||
}
|
||||
|
||||
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
|
||||
const who: string | undefined = node.who;
|
||||
let who: string | undefined = node.who;
|
||||
if (node.whoExpr) {
|
||||
const value = this.safeEval(node.whoExpr);
|
||||
who = typeof value === "string" ? value : undefined;
|
||||
}
|
||||
if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
|
||||
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0];
|
||||
return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" };
|
||||
@@ -891,6 +916,9 @@ export class Engine {
|
||||
const char = this.game.characters[who];
|
||||
if (!char) {
|
||||
const v = this.store[who];
|
||||
if (v && typeof v === "object" && v.__character) {
|
||||
return { char: { color: v.color ?? undefined }, name: this.interp(this.tr.str(v.name ?? who)), mode: "adv" };
|
||||
}
|
||||
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
|
||||
}
|
||||
let name = this.tr.str(char.name ?? "");
|
||||
@@ -927,7 +955,9 @@ export class Engine {
|
||||
if (!inMenu) this.interactionStart();
|
||||
if (node.with) await this.renderer.transition(this.transition(node.with));
|
||||
|
||||
let raw = this.interp(this.tr.line(node.tid, node.what));
|
||||
let raw = node.whatExpr
|
||||
? this.interp(pyStr(this.safeEval(node.whatExpr)))
|
||||
: this.interp(this.tr.line(node.tid, node.what));
|
||||
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
|
||||
let prefixLen = 0;
|
||||
if (mode === "extend" && this.lastSay) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// The small parts of the Python standard library games reach for: re, copy, time, datetime,
|
||||
// collections.defaultdict and functools.partial. Everything is pure computation.
|
||||
|
||||
import { PyDict, PyInstance, pyStr } from "./python";
|
||||
|
||||
/** dict that creates missing entries from a factory (collections.defaultdict). */
|
||||
export class PyDefaultDict extends PyDict {
|
||||
constructor(public factory: (() => any) | null, entries?: Iterable<[any, any]>) {
|
||||
super(entries);
|
||||
}
|
||||
missing(k: any) {
|
||||
const value = this.factory ? this.factory() : null;
|
||||
this.set(k, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class ReMatch {
|
||||
constructor(private m: RegExpExecArray) {}
|
||||
group(...which: (number | string)[]) {
|
||||
const pick = (w: number | string) => {
|
||||
const v = typeof w === "string" ? this.m.groups?.[w] : this.m[w];
|
||||
return v ?? null;
|
||||
};
|
||||
if (!which.length) return this.m[0];
|
||||
return which.length === 1 ? pick(which[0]) : which.map(pick);
|
||||
}
|
||||
groups() {
|
||||
return this.m.slice(1).map((v) => v ?? null);
|
||||
}
|
||||
groupdict() {
|
||||
return new PyDict(Object.entries(this.m.groups ?? {}));
|
||||
}
|
||||
start() {
|
||||
return this.m.index;
|
||||
}
|
||||
end() {
|
||||
return this.m.index + this.m[0].length;
|
||||
}
|
||||
}
|
||||
|
||||
const FLAGS: Record<number, string> = { 2: "i", 8: "m", 16: "s" };
|
||||
|
||||
function compile(pattern: string, flags = 0, global = false): RegExp {
|
||||
const js = pattern.replace(/\(\?P<(\w+)>/g, "(?<$1>").replace(/\(\?P=(\w+)\)/g, "\\k<$1>").replace(/\\Z/g, "$");
|
||||
const letters = Object.entries(FLAGS).filter(([bit]) => flags & Number(bit)).map(([, f]) => f).join("");
|
||||
return new RegExp(js, letters + (global ? "g" : "") + "u");
|
||||
}
|
||||
|
||||
function expand(replacement: string): string {
|
||||
// \1 and \g<name> are the group references of Python templates
|
||||
return replacement.replace(/\\(\d+)/g, "$$$1").replace(/\\g<(\w+)>/g, "$$<$1>");
|
||||
}
|
||||
|
||||
function deepcopy(v: any, seen = new Map<any, any>()): any {
|
||||
if (v === null || typeof v !== "object") return v;
|
||||
if (seen.has(v)) return seen.get(v);
|
||||
if (Array.isArray(v)) {
|
||||
const out: any[] = [];
|
||||
seen.set(v, out);
|
||||
v.forEach((x) => out.push(deepcopy(x, seen)));
|
||||
return out;
|
||||
}
|
||||
if (v instanceof PyDefaultDict) {
|
||||
const out = new PyDefaultDict(v.factory);
|
||||
seen.set(v, out);
|
||||
v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen)));
|
||||
return out;
|
||||
}
|
||||
if (v instanceof PyDict) {
|
||||
const out = new PyDict();
|
||||
seen.set(v, out);
|
||||
v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen)));
|
||||
return out;
|
||||
}
|
||||
if (v instanceof PyInstance) {
|
||||
const attrs: Record<string, any> = Object.create(null);
|
||||
const out = new PyInstance(v.cls, attrs);
|
||||
seen.set(v, out);
|
||||
for (const [k, x] of Object.entries(v.attrs)) attrs[k] = deepcopy(x, seen);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
||||
|
||||
class DateTime {
|
||||
constructor(public d: Date) {}
|
||||
get year() { return this.d.getFullYear(); }
|
||||
get month() { return this.d.getMonth() + 1; }
|
||||
get day() { return this.d.getDate(); }
|
||||
get hour() { return this.d.getHours(); }
|
||||
get minute() { return this.d.getMinutes(); }
|
||||
get second() { return this.d.getSeconds(); }
|
||||
weekday() { return (this.d.getDay() + 6) % 7; }
|
||||
strftime(format: string) { return strftime(format, this.d); }
|
||||
isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; }
|
||||
}
|
||||
|
||||
function strftime(format: string, d: Date): string {
|
||||
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => {
|
||||
switch (c) {
|
||||
case "Y": return String(d.getFullYear());
|
||||
case "y": return pad(d.getFullYear() % 100);
|
||||
case "m": return pad(d.getMonth() + 1);
|
||||
case "d": return pad(d.getDate());
|
||||
case "H": return pad(d.getHours());
|
||||
case "I": return pad(d.getHours() % 12 || 12);
|
||||
case "M": return pad(d.getMinutes());
|
||||
case "S": return pad(d.getSeconds());
|
||||
case "p": return d.getHours() < 12 ? "AM" : "PM";
|
||||
case "A": return days[d.getDay()];
|
||||
case "a": return days[d.getDay()].slice(0, 3);
|
||||
case "B": return months[d.getMonth()];
|
||||
case "b": return months[d.getMonth()].slice(0, 3);
|
||||
case "%": return "%";
|
||||
default: return "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const now = () => new DateTime(new Date());
|
||||
const datetimeClass = { now, today: now, utcnow: now };
|
||||
|
||||
/** Namespaces added to the sandbox's builtins. */
|
||||
export function pythonModules(): Record<string, any> {
|
||||
const re = {
|
||||
I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16,
|
||||
escape: (s: string) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
compile: (pattern: string, flags = 0) => ({
|
||||
search: (s: string) => re.search(pattern, s, flags),
|
||||
match: (s: string) => re.match(pattern, s, flags),
|
||||
sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags),
|
||||
findall: (s: string) => re.findall(pattern, s, flags),
|
||||
split: (s: string) => re.split(pattern, s, 0, flags),
|
||||
}),
|
||||
search: (pattern: string, s: string, flags = 0) => {
|
||||
const m = compile(pattern, flags).exec(String(s));
|
||||
return m ? new ReMatch(m) : null;
|
||||
},
|
||||
match: (pattern: string, s: string, flags = 0) => {
|
||||
const m = compile(`^(?:${pattern})`, flags).exec(String(s));
|
||||
return m ? new ReMatch(m) : null;
|
||||
},
|
||||
findall: (pattern: string, s: string, flags = 0) =>
|
||||
[...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])),
|
||||
sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => {
|
||||
let left = count || Infinity;
|
||||
const regex = compile(pattern, flags, true);
|
||||
return String(s).replace(regex, (...args: any[]) => {
|
||||
if (left-- <= 0) return args[0];
|
||||
if (typeof replacement === "function") return pyStr(replacement(new ReMatch(Object.assign(args.slice(0, -2).filter((x) => typeof x !== "object"), { index: args[args.length - 2], groups: undefined, input: s }) as any)));
|
||||
return String(replacement).replace(/\\(\d+)/g, (_m: string, n: string) => args[Number(n)] ?? "");
|
||||
});
|
||||
},
|
||||
split: (pattern: string, s: string, maxsplit = 0, flags = 0) => {
|
||||
const parts = String(s).split(compile(pattern, flags, true));
|
||||
return maxsplit ? [...parts.slice(0, maxsplit), parts.slice(maxsplit).join("")] : parts;
|
||||
},
|
||||
};
|
||||
void expand;
|
||||
const partial = (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args);
|
||||
const functionModules = { partial };
|
||||
return {
|
||||
re,
|
||||
copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) },
|
||||
time: {
|
||||
time: () => Date.now() / 1000,
|
||||
sleep: () => null,
|
||||
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
|
||||
},
|
||||
datetime: Object.assign({ datetime: datetimeClass, date: datetimeClass }, datetimeClass),
|
||||
collections: {
|
||||
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
|
||||
OrderedDict: () => new PyDict(),
|
||||
},
|
||||
functools: functionModules,
|
||||
};
|
||||
}
|
||||
@@ -192,6 +192,8 @@ function percentFormat(fmt: string, args: any): string {
|
||||
}
|
||||
|
||||
export function binop(op: string, a: any, b: any): any {
|
||||
// objects (colour matrices) may define their own arithmetic
|
||||
if (a && typeof a === "object" && typeof a.__binop__ === "function") return a.__binop__(op, b);
|
||||
switch (op) {
|
||||
case "+":
|
||||
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
|
||||
@@ -289,7 +291,13 @@ function methodOf(obj: any, name: string): any {
|
||||
index: (x) => obj.findIndex((y) => pyEq(y, x)),
|
||||
count: (x) => obj.filter((y) => pyEq(y, x)).length,
|
||||
reverse: () => void obj.reverse(),
|
||||
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
sort: (...a: any[]) => {
|
||||
const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {};
|
||||
const key = typeof kw.key === "function" ? kw.key : (x: any) => x;
|
||||
const sorted = obj.map((x) => [key(x), x]).sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0)).map((p) => p[1]);
|
||||
if (truthy(kw.reverse)) sorted.reverse();
|
||||
obj.splice(0, obj.length, ...sorted);
|
||||
},
|
||||
clear: () => void obj.splice(0),
|
||||
copy: () => [...obj],
|
||||
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
|
||||
@@ -316,6 +324,10 @@ function methodOf(obj: any, name: string): any {
|
||||
return obj.get(k);
|
||||
},
|
||||
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
|
||||
has_key: (k) => obj.has(k),
|
||||
iteritems: () => [...obj.map.entries()],
|
||||
iterkeys: () => [...obj.map.keys()],
|
||||
itervalues: () => [...obj.map.values()],
|
||||
clear: () => obj.map.clear(),
|
||||
copy: () => new PyDict(obj.map.entries()),
|
||||
};
|
||||
@@ -341,6 +353,35 @@ function methodOf(obj: any, name: string): any {
|
||||
},
|
||||
count: (s) => obj.split(s).length - 1,
|
||||
find: (s) => obj.indexOf(s),
|
||||
zfill: (n: number) => obj.padStart(n, "0"),
|
||||
ljust: (n: number, ch = " ") => obj.padEnd(n, ch),
|
||||
rjust: (n: number, ch = " ") => obj.padStart(n, ch),
|
||||
center: (n: number, ch = " ") => {
|
||||
const total = Math.max(0, n - obj.length);
|
||||
const left = Math.floor(total / 2);
|
||||
return ch.repeat(left) + obj + ch.repeat(total - left);
|
||||
},
|
||||
partition: (sep: string) => {
|
||||
const i = obj.indexOf(sep);
|
||||
return i < 0 ? [obj, "", ""] : [obj.slice(0, i), sep, obj.slice(i + sep.length)];
|
||||
},
|
||||
rpartition: (sep: string) => {
|
||||
const i = obj.lastIndexOf(sep);
|
||||
return i < 0 ? ["", "", obj] : [obj.slice(0, i), sep, obj.slice(i + sep.length)];
|
||||
},
|
||||
rsplit: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
|
||||
splitlines: () => obj.split(/\r?\n/),
|
||||
isupper: () => obj !== obj.toLowerCase() && obj === obj.toUpperCase(),
|
||||
islower: () => obj !== obj.toUpperCase() && obj === obj.toLowerCase(),
|
||||
isspace: () => obj.length > 0 && /^\s+$/.test(obj),
|
||||
isalnum: () => /^[\p{L}\p{N}]+$/u.test(obj),
|
||||
swapcase: () => [...obj].map((c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())).join(""),
|
||||
index: (x: string) => {
|
||||
const i = obj.indexOf(x);
|
||||
if (i < 0) throw new Error("substring not found");
|
||||
return i;
|
||||
},
|
||||
rfind: (x: string) => obj.lastIndexOf(x),
|
||||
isdigit: () => /^\d+$/.test(obj),
|
||||
isalpha: () => /^\p{L}+$/u.test(obj),
|
||||
};
|
||||
@@ -412,12 +453,16 @@ export class Python {
|
||||
const m = methodOf(o, e.a);
|
||||
if (m) return m;
|
||||
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
|
||||
// methods of host objects (datetime, regex matches) need their object
|
||||
if (typeof v === "function" && !(o instanceof PyDict) && !v.__py && Object.getPrototypeOf(o) !== Object.prototype)
|
||||
return v.bind(o);
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
case "sub": {
|
||||
const o = this.eval(e.o);
|
||||
const i = this.eval(e.i);
|
||||
if (o instanceof PyDict) {
|
||||
if (!o.has(i) && typeof (o as any).missing === "function") return (o as any).missing(i); // defaultdict
|
||||
if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i));
|
||||
return o.has(i) ? o.get(i) : null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The stage has the game's native resolution and is scaled with a CSS transform.
|
||||
|
||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
||||
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
||||
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
||||
|
||||
const POSITIONS: Record<string, Record<string, number>> = {
|
||||
@@ -93,25 +94,18 @@ export class Renderer {
|
||||
this.master.insertBefore(el, behind ?? later ?? null);
|
||||
}
|
||||
this.shown.set(tag, entry);
|
||||
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs]);
|
||||
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
|
||||
}
|
||||
|
||||
/** 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[]) {
|
||||
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
|
||||
if (this.fast) return;
|
||||
const live = { ...base };
|
||||
for (const spec of specs) {
|
||||
const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl;
|
||||
if (!block) continue;
|
||||
const runner = new AtlRunner({
|
||||
block: (name) => this.host.atl(name),
|
||||
props: (name) => this.host.transform(name),
|
||||
apply: (p) => this.style(entry.el, { ...p }),
|
||||
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
||||
}, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)));
|
||||
entry.runners.push({ runner, block });
|
||||
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
|
||||
if (block) this.startTimeline(entry.el, block, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)), entry.runners);
|
||||
}
|
||||
if (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners);
|
||||
}
|
||||
|
||||
hide(tag: string) {
|
||||
@@ -149,6 +143,16 @@ export class Renderer {
|
||||
const ref = this.host.resolveRef(def.ref);
|
||||
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
|
||||
}
|
||||
if (def.expr) {
|
||||
// an image defined by a Python expression: its value names a picture, evaluated when shown
|
||||
const value = this.host.evalExpr(def.expr);
|
||||
if (typeof value === "string") {
|
||||
const ref = this.host.resolveRef(value);
|
||||
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
||||
return this.build(ref ?? color ?? { file: value }, depth + 1);
|
||||
}
|
||||
return document.createElement("div");
|
||||
}
|
||||
if (def.switch) {
|
||||
const hit = def.switch.find(([c]) => this.host.evalCond(c));
|
||||
if (hit) return this.build(hit[1], depth + 1);
|
||||
@@ -192,6 +196,7 @@ export class Renderer {
|
||||
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
|
||||
return d;
|
||||
}
|
||||
if (def.timeline) return this.timelineBox(def, depth);
|
||||
if (def.frames?.length) return this.animated(def, depth);
|
||||
if (def.layered) return this.layered(def, depth);
|
||||
if (def.file) {
|
||||
@@ -210,6 +215,61 @@ export class Renderer {
|
||||
return document.createElement("div");
|
||||
}
|
||||
|
||||
/** Container of an ATL timeline that switches pictures; the first one is shown at once. */
|
||||
private timelineBox(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.style.display = "grid";
|
||||
box.dataset.timeline = "1";
|
||||
const first = firstPicture(def.timeline!);
|
||||
if (first) {
|
||||
const child = this.build(first, depth + 1);
|
||||
child.style.gridArea = "1 / 1";
|
||||
box.append(child);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Replace the picture shown in an animated element, optionally with a cross-fade. */
|
||||
private swap(box: HTMLElement, def: ImageDef, transition: Transition | null | undefined) {
|
||||
const child = this.build(def);
|
||||
child.style.gridArea = "1 / 1";
|
||||
const old = [...box.children];
|
||||
if (!transition || this.fast) {
|
||||
box.replaceChildren(child);
|
||||
return;
|
||||
}
|
||||
child.style.opacity = "0";
|
||||
box.append(child);
|
||||
child.animate([{ opacity: 0 }, { opacity: 1 }], { duration: (transition.d ?? 0.5) * 1000, fill: "forwards" })
|
||||
.onfinish = () => {
|
||||
child.style.opacity = "";
|
||||
old.forEach((e) => e.remove());
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a timeline on `el`; its properties are applied with the same styling as any shown image. */
|
||||
private startTimeline(el: HTMLElement, block: AtlBlock, base: Record<string, any>,
|
||||
vars: Map<string, any>, runners: Entry["runners"]) {
|
||||
const live = base;
|
||||
const runner = new AtlRunner({
|
||||
block: (name) => this.host.atl(name),
|
||||
props: (name) => this.host.transform(name),
|
||||
apply: (p) => this.style(el, { ...p }),
|
||||
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
||||
image: (img, transition) => this.swap(el, img, transition),
|
||||
contains: (img) => {
|
||||
const child = this.build(img);
|
||||
child.style.position = "absolute";
|
||||
child.style.left = child.style.top = "0";
|
||||
el.append(child);
|
||||
if (img.timeline) this.startTimeline(child, img.timeline, {}, new Map(), runners);
|
||||
},
|
||||
}, live, vars);
|
||||
runner.watch = el;
|
||||
runners.push({ runner, block });
|
||||
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
|
||||
}
|
||||
|
||||
/** An element for an image definition, for UI built outside the scene (screens). */
|
||||
displayable(def: ImageDef): HTMLElement {
|
||||
return this.build(def);
|
||||
@@ -322,6 +382,7 @@ export class Renderer {
|
||||
const filters: string[] = [];
|
||||
if (p.filter) filters.push(`${p.filter}(1)`);
|
||||
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`);
|
||||
if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
|
||||
if (filters.length) el.style.filter = filters.join(" ");
|
||||
}
|
||||
|
||||
@@ -384,6 +445,16 @@ export class Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/** The first picture an ATL timeline shows (what is on screen before it starts). */
|
||||
function firstPicture(block: AtlBlock): ImageDef | null {
|
||||
for (const step of block.steps) {
|
||||
if (step.op === "image") return step.img;
|
||||
if (step.op === "parallel") for (const b of step.blocks) { const f = firstPicture(b); if (f) return f; }
|
||||
if (step.op === "block") { const f = firstPicture(step.block); if (f) return f; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalize(p: Record<string, any>) {
|
||||
if (p.align) [p.xalign, p.yalign] = p.align;
|
||||
if (p.pos) [p.xpos, p.ypos] = p.pos;
|
||||
|
||||
@@ -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 { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
|
||||
import { PyDict, pyStr, truthy, type Python } from "./python";
|
||||
import type { AtSpec, Expr } from "./types";
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface ScreenHost {
|
||||
mainMenu(): void;
|
||||
openMenu(): void;
|
||||
openUrl(url: string): void;
|
||||
quickSave(): void;
|
||||
quickLoad(): void;
|
||||
setLanguage(code: string | null): void;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
atl(name: string): AtlBlock | null;
|
||||
@@ -660,6 +663,68 @@ export class ScreenManager {
|
||||
const result = typeof a === "function" ? a(0, 0) : null;
|
||||
return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1);
|
||||
}
|
||||
case "text": {
|
||||
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;
|
||||
return box;
|
||||
}
|
||||
case "image": return this.displayable(a, depth + 1);
|
||||
case "matrixcolor": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (child && b instanceof ColorMatrix) child.style.filter = matrixFilter(b);
|
||||
return child;
|
||||
}
|
||||
case "scale": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (child && num(value.args[1]) !== null) {
|
||||
child.style.width = `${value.args[1]}px`;
|
||||
child.style.height = `${value.args[2]}px`;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
case "factorscale": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (child && num(b) !== null) {
|
||||
child.style.transformOrigin = "0 0";
|
||||
child.style.transform = `scale(${b}, ${value.args[2] ?? b})`;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
case "flip": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (child) child.style.transform = `scale(${truthy(value.kw.horizontal) ? -1 : 1}, ${truthy(value.kw.vertical) ? -1 : 1})`;
|
||||
return child;
|
||||
}
|
||||
case "filter": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (child) {
|
||||
const how = String(value.kw.how);
|
||||
if (how === "alpha") child.style.opacity = String(b ?? 1);
|
||||
else child.style.filter = how === "blur" ? `blur(${b ?? 0}px)` : `${how}(1)`;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
case "composite": {
|
||||
const box = document.createElement("div");
|
||||
const size = a;
|
||||
box.style.position = "relative";
|
||||
if (Array.isArray(size)) {
|
||||
box.style.width = `${size[0]}px`;
|
||||
box.style.height = `${size[1]}px`;
|
||||
}
|
||||
for (let i = 1; i + 1 < value.args.length; i += 2) {
|
||||
const child = this.displayable(value.args[i + 1], depth + 1);
|
||||
const at = value.args[i];
|
||||
if (!child || !Array.isArray(at)) continue;
|
||||
child.style.position = "absolute";
|
||||
child.style.left = `${at[0]}px`;
|
||||
child.style.top = `${at[1]}px`;
|
||||
box.append(child);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
case "transform": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
if (!child) return null;
|
||||
@@ -763,6 +828,10 @@ export class ScreenManager {
|
||||
const paint = () => {
|
||||
const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive")
|
||||
: selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle";
|
||||
if (el.dataset.state && el.dataset.state !== state) {
|
||||
// `on hover:` / `on idle:` handlers of the button's transforms
|
||||
for (const entry of (el as any).__atl ?? []) entry.runner.event(entry.block, state);
|
||||
}
|
||||
el.dataset.state = state;
|
||||
if (images) {
|
||||
const file = images[state];
|
||||
@@ -867,6 +936,7 @@ export class ScreenManager {
|
||||
const style = el.style;
|
||||
const alpha = num(g("alpha"));
|
||||
if (alpha !== null) style.opacity = String(alpha);
|
||||
applyFilters(el, { blur: g("blur"), matrixcolor: g("matrixcolor") });
|
||||
// size
|
||||
let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")];
|
||||
const both = g("xysize");
|
||||
@@ -979,7 +1049,7 @@ export class ScreenManager {
|
||||
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
|
||||
el.style.transform = parts.join(" ");
|
||||
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
||||
if (typeof p.blur === "number") el.style.filter = p.blur > 0 ? `blur(${p.blur}px)` : "";
|
||||
applyFilters(el, p);
|
||||
};
|
||||
for (const spec of specs) {
|
||||
Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
|
||||
@@ -1000,6 +1070,8 @@ export class ScreenManager {
|
||||
apply: (p) => apply(p),
|
||||
eval: (expr, frame) => this.evalWith(expr, frame),
|
||||
}, live, bindArguments(block, spec, (e) => this.ev(e)));
|
||||
runner.watch = el;
|
||||
((el as any).__atl ??= []).push({ runner, block });
|
||||
ctx.inst.runners.push(runner);
|
||||
const inst = ctx.inst;
|
||||
void runner.start(block).then((completed) => {
|
||||
@@ -1095,7 +1167,10 @@ export class ScreenManager {
|
||||
this.assign(a, truthy(now) && now !== off ? off : on, owner);
|
||||
return;
|
||||
}
|
||||
case "SetLocalVariable":
|
||||
case "SetScreenVariable": owner?.vars.set(a, b); return;
|
||||
case "QuickSave": this.host.quickSave(); return;
|
||||
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
|
||||
case "ToggleScreenVariable": {
|
||||
const now = owner?.vars.get(a);
|
||||
const on = action.args.length > 1 ? b : true;
|
||||
@@ -1148,7 +1223,7 @@ export class ScreenManager {
|
||||
for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
|
||||
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||
"OpenURL"]) out[kind] = make(kind);
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) out[kind] = make(kind);
|
||||
const shape = (kind: string) => (...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
|
||||
@@ -1162,6 +1237,27 @@ export class ScreenManager {
|
||||
out.Transform = shape("transform");
|
||||
out.DynamicDisplayable = shape("dynamic");
|
||||
out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value));
|
||||
// Character(...) built by game code: kept as a record the say statement can read
|
||||
out.Character = (...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
const kw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
||||
return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw };
|
||||
};
|
||||
out.Text = shape("text");
|
||||
out.Image = shape("image");
|
||||
out.Fixed = shape("composite");
|
||||
out.AlphaMask = (base: any) => base;
|
||||
out.At = (child: any) => child;
|
||||
Object.assign(out, colorMatrices);
|
||||
out.im = {
|
||||
MatrixColor: shape("matrixcolor"), Scale: shape("scale"), FactorScale: shape("factorscale"), Flip: shape("flip"),
|
||||
Crop: shape("crop"), Composite: shape("composite"),
|
||||
Grayscale: (child: any) => new Displayable("filter", [child], { how: "grayscale" }),
|
||||
Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }),
|
||||
Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }),
|
||||
Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }),
|
||||
matrix: legacyMatrices,
|
||||
};
|
||||
out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
|
||||
out.GetTooltip = () => this.tooltip;
|
||||
out.StaticValue = (value: number, range = 1) => new BarValue(() => value, () => range);
|
||||
@@ -1191,6 +1287,15 @@ export class ScreenManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** blur and matrixcolor of a widget or transform, combined into one CSS filter. */
|
||||
function applyFilters(el: HTMLElement, p: Props) {
|
||||
const filters: string[] = [];
|
||||
if (num(p.blur) !== null && p.blur > 0) filters.push(`blur(${p.blur}px)`);
|
||||
if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
|
||||
if (filters.length) el.style.filter = filters.join(" ");
|
||||
else if (p.blur !== undefined || p.matrixcolor !== undefined) el.style.filter = "";
|
||||
}
|
||||
|
||||
function lengthOf(v: any): string {
|
||||
return typeof v === "number" ? PX_UNIT(v) : "0px";
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface ImageDef {
|
||||
props?: Record<string, any>;
|
||||
frames?: { img: ImageDef; d?: number }[];
|
||||
repeat?: boolean;
|
||||
/** ATL timeline that switches pictures and moves them (image blocks with parallel, contains ...). */
|
||||
timeline?: import("./atl").AtlBlock;
|
||||
layered?: Layer[];
|
||||
at?: AtSpec[];
|
||||
selected?: string[]; // attributes chosen for a layered image (set by the engine)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
Object.assign(globalThis, { window, document });
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
|
||||
|
||||
const compiled = await build({
|
||||
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`,
|
||||
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
|
||||
});
|
||||
const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||
const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const name = (id) => ({ k: "name", id });
|
||||
const attr = (o, a) => ({ k: "attr", o, a });
|
||||
const call = (f, ...a) => ({ k: "call", f, a: a.map((v) => (v && v.k ? v : { k: "const", v })), kw: {} });
|
||||
|
||||
function python(extra = {}) {
|
||||
const store = {};
|
||||
const builtins = { ...pythonModules(), ...colorMatrices, ...extra };
|
||||
const py = new Python({
|
||||
get: (n) => (n in store ? store[n] : builtins[n]),
|
||||
has: (n) => n in store || n in builtins,
|
||||
set: (n, v) => { store[n] = v; },
|
||||
del: (n) => { delete store[n]; },
|
||||
});
|
||||
return { py, store };
|
||||
}
|
||||
|
||||
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
|
||||
const brighter = colorMatrices.BrightnessMatrix(0.2);
|
||||
const half = colorMatrices.TintMatrix("#808080");
|
||||
const both = half.times(brighter); // brighten, then tint
|
||||
assert.ok(Math.abs(both.rows[0][4] - 0.2 * (128 / 255)) < 1e-6);
|
||||
assert.deepEqual(ColorMatrix.identity().times(brighter).rows, brighter.rows);
|
||||
const gray = colorMatrices.SaturationMatrix(0);
|
||||
assert.ok(Math.abs(gray.rows[0][0] - 0.2126) < 1e-9 && Math.abs(gray.rows[1][2] - 0.0722) < 1e-9);
|
||||
assert.equal(legacyMatrices.tint(1, 0.5, 0).rows[1][1], 0.5);
|
||||
assert.match(matrixFilter(both), /^url\(#pgm-\d+\)$/);
|
||||
assert.equal(matrixFilter(both), matrixFilter(both), "equal matrices share one SVG filter");
|
||||
assert.equal(document.querySelectorAll("feColorMatrix").length, 1);
|
||||
});
|
||||
|
||||
test("the sandbox multiplies matrices with `*` and refuses other arithmetic on them", () => {
|
||||
const { py, store } = python();
|
||||
py.exec([{ op: "set", t: name("m"), v: { k: "bin", op: "*", l: call(name("TintMatrix"), "#ff0000"), r: call(name("BrightnessMatrix"), 0.1) } }]);
|
||||
assert.ok(store.m instanceof ColorMatrix);
|
||||
assert.throws(() => py.eval({ k: "bin", op: "+", l: name("m"), r: name("m") }), /перемножать/);
|
||||
});
|
||||
|
||||
test("re, copy, defaultdict, datetime and string helpers behave like their Python versions", () => {
|
||||
const { py, store } = python();
|
||||
const re = (fn, ...args) => py.eval(call(attr(name("re"), fn), ...args));
|
||||
assert.equal(re("sub", "(\\d+)", "<\\1>", "a12b3"), "a<12>b<3>");
|
||||
assert.equal(re("match", "^ab", "abc").group(), "ab");
|
||||
assert.equal(re("search", "(?P<n>\\d+)", "x42").group("n"), "42");
|
||||
assert.deepEqual(re("findall", "\\d", "a1b2"), ["1", "2"]);
|
||||
const original = new PyDict([["k", [1, 2]]]);
|
||||
store.original = original;
|
||||
const copied = py.eval(call(attr(name("copy"), "deepcopy"), name("original")));
|
||||
copied.get("k").push(3);
|
||||
assert.deepEqual(original.get("k"), [1, 2]);
|
||||
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), { k: "name", id: "list" }));
|
||||
py.scope.set("list", () => []);
|
||||
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), name("list")));
|
||||
py.eval(call(attr({ k: "sub", o: name("counts"), i: { k: "const", v: "a" } }, "append"), 1));
|
||||
assert.deepEqual(store.counts.get("a"), [1]);
|
||||
assert.equal(py.eval(call(attr({ k: "const", v: "7" }, "zfill"), 3)), "007");
|
||||
assert.deepEqual(py.eval(call(attr({ k: "const", v: "a=b" }, "partition"), "=")), ["a", "=", "b"]);
|
||||
const hour = py.eval(attr(call(attr(name("datetime"), "now")), "hour"));
|
||||
assert.ok(hour >= 0 && hour < 24);
|
||||
});
|
||||
|
||||
test("ATL image steps, finite repeat, time and function work in a timeline", async () => {
|
||||
const shown = [];
|
||||
const props = {};
|
||||
const { py } = python();
|
||||
py.scope.set("drift", (trans) => { trans.xpos = 0.75; return null; });
|
||||
const host = {
|
||||
block: () => null, props: () => null, apply: () => {},
|
||||
eval: (expr) => py.eval(expr),
|
||||
image: (img, transition) => shown.push([img.file, transition?.d]),
|
||||
};
|
||||
const runner = new AtlRunner(host, props);
|
||||
const started = performance.now();
|
||||
const completed = await runner.start({ repeat: 2, steps: [
|
||||
{ op: "image", img: { file: "a.webp" }, with: { t: "dissolve", d: 0.2 } },
|
||||
{ op: "pause", t: 0.02 },
|
||||
{ op: "image", img: { file: "b.webp" }, with: null },
|
||||
{ op: "time", t: 0.05 },
|
||||
] });
|
||||
assert.equal(completed, true);
|
||||
assert.deepEqual(shown, [["a.webp", 0.2], ["b.webp", undefined], ["a.webp", 0.2], ["b.webp", undefined]]);
|
||||
assert.ok(performance.now() - started >= 95, "`time 0.05` waits for the block clock, twice");
|
||||
await new AtlRunner(host, props).start({ repeat: null, steps: [{ op: "function", f: { k: "name", id: "drift" } }] });
|
||||
assert.equal(props.xpos, 0.75, "function f mutates the transform properties");
|
||||
});
|
||||
|
||||
test("an animation stops by itself when its element leaves the page", async () => {
|
||||
const box = document.createElement("div");
|
||||
document.body.append(box);
|
||||
let frames = 0;
|
||||
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => { frames++; } }, {});
|
||||
runner.watch = box;
|
||||
const running = runner.start({ repeat: true, steps: [{ op: "move", warp: "linear", t: 0.02, props: { alpha: 0 } }] });
|
||||
await wait(40);
|
||||
box.remove();
|
||||
assert.equal(await running, false, "stopped, not finished");
|
||||
const seen = frames;
|
||||
await wait(40);
|
||||
assert.equal(frames, seen);
|
||||
});
|
||||
|
||||
test("on hover handlers run when the button reports the event", async () => {
|
||||
const props = { alpha: 1 };
|
||||
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => {} }, props);
|
||||
const block = { repeat: null, steps: [
|
||||
{ op: "on", events: ["hover"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 0.5 } }] } },
|
||||
{ op: "on", events: ["idle"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 1 } }] } },
|
||||
] };
|
||||
runner.event(block, "hover");
|
||||
await wait(20);
|
||||
assert.equal(props.alpha, 0.5);
|
||||
runner.event(block, "idle");
|
||||
await wait(20);
|
||||
assert.equal(props.alpha, 1);
|
||||
});
|
||||
Reference in new issue
Block a user