feat: File* save/load actions, hashlib/uuid, dict spread and small renpy helpers in the player
Games with their own save screens work: FileAction/FileSave/FileLoad/FileDelete/FilePage*, FileLoadable,
FileTime, FileSaveName, FileJson and friends use browser slots (a slot has no screenshot). Added
renpy.has_image/has_screen/list_files/get_mouse_pos/log/jump_out_of_context/random.seed,
renpy.translation.translate_string, type/id/vars/Flatten/LiveComposite/Composite, bare defaultdict/
deepcopy, md5/sha256 (checked against test vectors), uuid4, time.monotonic, re.compile().fullmatch,
math.isfinite and `{**a, "k": v}`. The converter whitelists them and accepts the hashlib/uuid imports.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c1e1aee01
commit
16ba20816c
8 files changed
+366
-20
No files matched your search
@@ -170,9 +170,19 @@ export class Engine {
|
||||
this.renderer.fit(this.container);
|
||||
window.addEventListener("keydown", this.onKey);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
window.addEventListener("pointermove", this.onPointer);
|
||||
}
|
||||
|
||||
/** The pointer in the game's own coordinates (`renpy.get_mouse_pos`). */
|
||||
private mousePos: [number, number] = [0, 0];
|
||||
private onPointer = (event: PointerEvent) => {
|
||||
const box = this.container.getBoundingClientRect();
|
||||
const [w, h] = this.game.resolution ?? [1920, 1080];
|
||||
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
|
||||
};
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("pointermove", this.onPointer);
|
||||
this.liveImages?.destroy();
|
||||
this.screens.destroy();
|
||||
this.gen++;
|
||||
@@ -338,7 +348,18 @@ export class Engine {
|
||||
const kwless = (args: any[]) =>
|
||||
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
|
||||
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
|
||||
const makeRandom = (next: () => number): Record<string, any> => {
|
||||
const seeded = (seed: any) => {
|
||||
let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
const makeRandom = (source: () => number): Record<string, any> => {
|
||||
let next = source;
|
||||
const below = (n: number) => Math.floor(next() * n);
|
||||
const self: Record<string, any> = {
|
||||
randint: (a: number, b: number) => a + below(b - a + 1),
|
||||
@@ -383,19 +404,11 @@ export class Engine {
|
||||
}
|
||||
},
|
||||
uniform: (a: number, b: number) => a + next() * (b - a),
|
||||
seed: () => null,
|
||||
seed: (seed?: any) => { next = seed === undefined || seed === null ? Math.random : seeded(seed); return null; },
|
||||
};
|
||||
// `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable
|
||||
self.Random = (seed?: any) => {
|
||||
if (seed === undefined || seed === null) return makeRandom(Math.random);
|
||||
let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0;
|
||||
return makeRandom(() => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
});
|
||||
return makeRandom(seed === undefined || seed === null ? Math.random : seeded(seed));
|
||||
};
|
||||
return self;
|
||||
};
|
||||
@@ -427,7 +440,19 @@ export class Engine {
|
||||
});
|
||||
const renpyImpl: Record<string, any> = {
|
||||
random,
|
||||
translation: { translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null) },
|
||||
style: { rebuild: () => null },
|
||||
// diagnostics only: nothing is written anywhere
|
||||
log: () => null,
|
||||
write_log: () => null,
|
||||
get_filename_line: () => ["", 0],
|
||||
has_image: (name: any) => this.resolveImage(pyStr(name).split(" ")) !== null,
|
||||
has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]),
|
||||
list_files: () => Object.keys(this.game.files ?? {}),
|
||||
get_mouse_pos: () => [...this.mousePos],
|
||||
jump_out_of_context: (label: string) => {
|
||||
throw new JumpSignal("jump", label);
|
||||
},
|
||||
image_size: (name: any) => {
|
||||
const text = pyStr(name);
|
||||
const resolved = this.resolveImage(text.split(" "));
|
||||
@@ -584,7 +609,7 @@ export class Engine {
|
||||
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
||||
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
||||
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
||||
isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
|
||||
isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
|
||||
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
|
||||
},
|
||||
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
||||
@@ -911,6 +936,16 @@ export class Engine {
|
||||
endReplay: () => this.endReplay(),
|
||||
quickSave: () => void this.save("quick"),
|
||||
quickLoad: () => void this.load("quick"),
|
||||
slotInfo: (slot) => {
|
||||
if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); }
|
||||
const meta = this.slotCache.get(slot);
|
||||
return meta ? { title: meta.title, time: meta.updated_at } : null;
|
||||
},
|
||||
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
|
||||
loadSlot: (slot) => void this.load(slot),
|
||||
deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm),
|
||||
get filePage() { return String(engine.persistent._file_page ?? "1"); },
|
||||
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
|
||||
transform: (name) => this.game.transforms[name] ?? null,
|
||||
atl: (name) => this.game.atl?.[name] ?? null,
|
||||
};
|
||||
@@ -1730,6 +1765,30 @@ export class Engine {
|
||||
};
|
||||
}
|
||||
|
||||
private slotCache = new Map<string, { title: string; updated_at: string }>();
|
||||
private slotsRead = false;
|
||||
|
||||
/** Slot metadata for the File* actions of a game's own save screens (read once, then kept up to date). */
|
||||
private async refreshSlots() {
|
||||
try {
|
||||
this.slotCache = new Map((await this.saves.list()).map((meta) => [meta.slot, meta]));
|
||||
} catch { /* storage unavailable: slots look empty */ }
|
||||
this.screens.refresh();
|
||||
}
|
||||
|
||||
private async saveFileSlot(slot: string, confirm: boolean) {
|
||||
if (confirm && this.slotCache.has(slot) && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
|
||||
await this.save(slot);
|
||||
await this.refreshSlots();
|
||||
}
|
||||
|
||||
private async deleteFileSlot(slot: string, confirm: boolean) {
|
||||
if (!this.slotCache.has(slot)) return;
|
||||
if (confirm && !(await this.confirmAsk("Удалить сохранение?", "Удалить"))) return;
|
||||
await this.saves.remove(slot).catch(() => {});
|
||||
await this.refreshSlots();
|
||||
}
|
||||
|
||||
private async save(slot: string, silent = false) {
|
||||
if (!this.running || !this.cur.label) return;
|
||||
const data = this.snapshotState();
|
||||
|
||||
@@ -98,7 +98,7 @@ class DateTime {
|
||||
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 {
|
||||
export 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) => {
|
||||
@@ -176,6 +176,83 @@ Object.assign(DateTime.prototype, {
|
||||
},
|
||||
});
|
||||
|
||||
function md5(bytes: Uint8Array): string {
|
||||
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0);
|
||||
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
|
||||
let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
|
||||
const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(padded.length - 8, (bytes.length * 8) >>> 0, true);
|
||||
view.setUint32(padded.length - 4, Math.floor(bytes.length * 8 / 2 ** 32), true);
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
const m = Array.from({ length: 16 }, (_, i) => view.getUint32(off + i * 4, true));
|
||||
let a = a0, b = b0, c = c0, d = d0;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let f: number, g: number;
|
||||
if (i < 16) { f = (b & c) | (~b & d); g = i; }
|
||||
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
|
||||
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
|
||||
else { f = c ^ (b | ~d); g = (7 * i) % 16; }
|
||||
f = (f + a + K[i] + m[g]) >>> 0;
|
||||
a = d; d = c; c = b;
|
||||
const s = S[(i >> 4) * 4 + (i % 4)];
|
||||
b = (b + ((f << s) | (f >>> (32 - s)))) >>> 0;
|
||||
}
|
||||
a0 = (a0 + a) >>> 0; b0 = (b0 + b) >>> 0; c0 = (c0 + c) >>> 0; d0 = (d0 + d) >>> 0;
|
||||
}
|
||||
return [a0, b0, c0, d0].map((v) => [0, 8, 16, 24].map((sh) => ((v >>> sh) & 255).toString(16).padStart(2, "0")).join("")).join("");
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
const K = Array.from({ length: 64 }, (_, i) => {
|
||||
let n = 2, count = 0;
|
||||
for (; count <= i; n++) if (Array.from({ length: n - 2 }, (_, d) => d + 2).every((d) => n % d)) count++;
|
||||
return Math.floor((Math.cbrt(n - 1) % 1) * 2 ** 32) >>> 0;
|
||||
});
|
||||
const h = [2, 3, 5, 7, 11, 13, 17, 19].map((p) => Math.floor((Math.sqrt(p) % 1) * 2 ** 32) >>> 0);
|
||||
const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
|
||||
view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0);
|
||||
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
const w = Array.from({ length: 64 }, (_, i) => (i < 16 ? view.getUint32(off + i * 4) : 0));
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, hh] = h;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
||||
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||
hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
[a, b, c, d, e, f, g, hh].forEach((v, i) => { h[i] = (h[i] + v) >>> 0; });
|
||||
}
|
||||
return h.map((v) => v.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
/** `hashlib.md5(data)`: the data may come later through `update`. */
|
||||
class Hash {
|
||||
private parts: Uint8Array[] = [];
|
||||
constructor(private digest: (bytes: Uint8Array) => string, data?: any) { if (data !== undefined) this.update(data); }
|
||||
update(data: any) {
|
||||
this.parts.push(typeof data === "string" ? new TextEncoder().encode(data) : Uint8Array.from(data ?? []));
|
||||
return null;
|
||||
}
|
||||
hexdigest() {
|
||||
const all = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0));
|
||||
let at = 0;
|
||||
for (const p of this.parts) { all.set(p, at); at += p.length; }
|
||||
return this.digest(all);
|
||||
}
|
||||
}
|
||||
|
||||
/** Namespaces added to the sandbox's builtins. */
|
||||
export function pythonModules(): Record<string, any> {
|
||||
const re = {
|
||||
@@ -186,6 +263,7 @@ export function pythonModules(): Record<string, any> {
|
||||
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),
|
||||
fullmatch: (s: string) => re.fullmatch(pattern, s, flags),
|
||||
split: (s: string) => re.split(pattern, s, 0, flags),
|
||||
}),
|
||||
search: (pattern: string, s: string, flags = 0) => {
|
||||
@@ -196,6 +274,10 @@ export function pythonModules(): Record<string, any> {
|
||||
const m = compile(`^(?:${pattern})`, flags).exec(String(s));
|
||||
return m ? new ReMatch(m) : null;
|
||||
},
|
||||
fullmatch: (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) => {
|
||||
@@ -220,6 +302,7 @@ export function pythonModules(): Record<string, any> {
|
||||
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,
|
||||
monotonic: () => performance.now() / 1000,
|
||||
sleep: () => null,
|
||||
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
|
||||
},
|
||||
@@ -229,5 +312,18 @@ export function pythonModules(): Record<string, any> {
|
||||
OrderedDict: () => new PyDict(),
|
||||
},
|
||||
functools: functionModules,
|
||||
hashlib: {
|
||||
md5: (data?: any) => new Hash(md5, data),
|
||||
sha256: (data?: any) => new Hash(sha256, data),
|
||||
},
|
||||
uuid: {
|
||||
uuid4: () => ({ hex: crypto.randomUUID().replaceAll("-", ""), toString: () => crypto.randomUUID() }),
|
||||
},
|
||||
// `from collections import defaultdict` and friends leave the bare names
|
||||
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
|
||||
OrderedDict: () => new PyDict(),
|
||||
deepcopy: (v: any) => deepcopy(v),
|
||||
partial,
|
||||
timedelta,
|
||||
};
|
||||
}
|
||||
@@ -680,7 +680,17 @@ export class Python {
|
||||
case "tuple":
|
||||
return e.v.map((x: Expr) => this.eval(x));
|
||||
case "dict":
|
||||
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
|
||||
{
|
||||
const out = new PyDict();
|
||||
for (const [k, v] of e.v as [Expr, Expr][]) {
|
||||
if (k.k === "star") {
|
||||
const source = this.eval(k.v);
|
||||
if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x));
|
||||
else if (source !== null) throw new Error("** ожидает словарь");
|
||||
} else out.set(this.eval(k), this.eval(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "fmt":
|
||||
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
|
||||
case "comp": {
|
||||
@@ -1229,6 +1239,11 @@ export class Python {
|
||||
case "dict": {
|
||||
const out = new PyDict();
|
||||
for (const [k, v] of e.v as [Expr, Expr][]) {
|
||||
if (k.k === "star") {
|
||||
const source = yield* this.evalG(k.v);
|
||||
if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x));
|
||||
continue;
|
||||
}
|
||||
const key = yield* this.evalG(k);
|
||||
out.set(key, yield* this.evalG(v));
|
||||
}
|
||||
@@ -1461,6 +1476,9 @@ export function decodeValue(v: any): any {
|
||||
}
|
||||
|
||||
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
||||
const identities = new WeakMap<object, number>();
|
||||
let lastIdentity = 0;
|
||||
|
||||
export function userBuiltins(py: Python): Record<string, any> {
|
||||
const split = (args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
@@ -1522,6 +1540,21 @@ export function userBuiltins(py: Python): Record<string, any> {
|
||||
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||
},
|
||||
iter: (x: any) => [...iterate(x)],
|
||||
type: (x: any) => {
|
||||
if (x instanceof PyInstance) return x.cls;
|
||||
if (x instanceof PyDict) return typeOf("dict");
|
||||
if (Array.isArray(x)) return typeOf("list");
|
||||
if (typeof x === "boolean") return typeOf("bool");
|
||||
if (typeof x === "number") return typeOf(Number.isInteger(x) ? "int" : "float");
|
||||
if (typeof x === "string") return typeOf("str");
|
||||
return null;
|
||||
},
|
||||
id: (x: any) => {
|
||||
if (x === null || typeof x !== "object") return typeof x === "number" ? x : 0;
|
||||
if (!identities.has(x)) identities.set(x, ++lastIdentity);
|
||||
return identities.get(x)!;
|
||||
},
|
||||
vars: (x: any) => (x instanceof PyInstance ? new PyDict(Object.entries(x.attrs)) : new PyDict()),
|
||||
hash: (x: any) => {
|
||||
const text = String(key(x));
|
||||
return [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isNativeAction } from "./musicroom";
|
||||
import { galleryClass } from "./gallery";
|
||||
import { parseExpression } from "./pyparse";
|
||||
import { movieDisplayable } from "./movie";
|
||||
import { strftime } from "./pymodules";
|
||||
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
||||
import type { AtSpec, Expr } from "./types";
|
||||
|
||||
@@ -43,6 +44,13 @@ export interface ScreenHost {
|
||||
openUrl(url: string): void;
|
||||
quickSave(): void;
|
||||
quickLoad(): void;
|
||||
/** Slot metadata for File* actions; null when empty or not yet read. */
|
||||
slotInfo(slot: string): { title: string; time: string } | null;
|
||||
saveSlot(slot: string, confirm: boolean): void;
|
||||
loadSlot(slot: string): void;
|
||||
deleteSlot(slot: string, confirm: boolean): void;
|
||||
/** The page of the save/load screen (`persistent._file_page`). */
|
||||
filePage: string;
|
||||
startReplay(label: string, scope: Record<string, any> | null): void;
|
||||
endReplay(): void;
|
||||
setLanguage(code: string | null): void;
|
||||
@@ -1406,6 +1414,11 @@ export class ScreenManager {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The save slot a File* action means: its name on the current (or the given) page. */
|
||||
private slotId(name: any, page?: any): string {
|
||||
return `file:${page ?? this.host.filePage}-${pyStr(name)}`;
|
||||
}
|
||||
|
||||
private perform(action: any, screen: Instance | null) {
|
||||
if (action === null || action === undefined || action === false) return;
|
||||
if (Array.isArray(action)) {
|
||||
@@ -1454,6 +1467,33 @@ export class ScreenManager {
|
||||
throw new ActionAbort();
|
||||
}
|
||||
case "EndReplay": this.host.endReplay(); throw new ActionAbort();
|
||||
case "FileAction":
|
||||
case "FileSave":
|
||||
case "FileLoad": {
|
||||
const slot = this.slotId(a, action.kw.page);
|
||||
const loading = action.kind === "FileLoad" || (action.kind === "FileAction" && /load/i.test(owner?.name ?? ""));
|
||||
const sure = truthy(action.kw.confirm ?? (action.kind === "FileLoad" ? b : false) ?? false);
|
||||
if (loading) { this.host.loadSlot(slot); throw new ActionAbort(); }
|
||||
this.host.saveSlot(slot, sure);
|
||||
return;
|
||||
}
|
||||
case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return;
|
||||
case "FileTakeScreenshot": return;
|
||||
case "FilePage": this.host.filePage = String(a); return;
|
||||
case "FilePageNext":
|
||||
case "FilePagePrevious": {
|
||||
const kw = action.kw;
|
||||
const max = typeof kw.max === "number" ? kw.max : typeof a === "number" ? a : null;
|
||||
const order = [...(kw.auto ?? true ? ["auto"] : []), ...(kw.quick ?? true ? ["quick"] : [])];
|
||||
const numbered = max === null ? 0 : max;
|
||||
const pages = [...order, ...Array.from({ length: numbered || 99 }, (_, i) => String(i + 1))];
|
||||
const here = pages.indexOf(this.host.filePage);
|
||||
const step = action.kind === "FilePageNext" ? 1 : -1;
|
||||
let next = (here < 0 ? pages.indexOf("1") : here) + step;
|
||||
if (next < 0 || next >= pages.length) next = truthy(kw.wrap) ? (next + pages.length) % pages.length : Math.max(0, Math.min(pages.length - 1, next));
|
||||
this.host.filePage = pages[next];
|
||||
return;
|
||||
}
|
||||
case "QuickSave": this.host.quickSave(); return;
|
||||
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
|
||||
case "ToggleScreenVariable": {
|
||||
@@ -1532,7 +1572,37 @@ export class ScreenManager {
|
||||
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
||||
"ToggleFocus", "PauseAudio", "SetMixer"]) out[kind] = make(kind);
|
||||
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
|
||||
"FilePage", "FilePageNext", "FilePagePrevious"]) out[kind] = make(kind);
|
||||
// values of the save/load screens; screenshots are not stored, so a slot has none
|
||||
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
||||
const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page);
|
||||
out.FileLoadable = (...a: any[]) => this.host.slotInfo(slotOf(a)) !== null;
|
||||
out.FileUsedSlot = out.FileLoadable;
|
||||
out.FileNewest = () => false;
|
||||
out.FileSaveName = (...a: any[]) => this.host.slotInfo(slotOf(a))?.title ?? "";
|
||||
out.FileTime = (...a: any[]) => {
|
||||
const kw = kwOf(a), info = this.host.slotInfo(slotOf(a));
|
||||
if (!info) return kw.empty ?? a[2] ?? "";
|
||||
return strftime(String(kw.format ?? (typeof a[1] === "string" ? a[1] : "%b %d, %H:%M")), new Date(info.time));
|
||||
};
|
||||
out.FileJson = (...a: any[]) => {
|
||||
const kw = kwOf(a), info = this.host.slotInfo(slotOf(a));
|
||||
if (!info) return kw.empty ?? null;
|
||||
return kw.key !== undefined ? kw.missing ?? null : new PyDict();
|
||||
};
|
||||
out.FileScreenshot = (...a: any[]) => kwOf(a).empty ?? null;
|
||||
out.FileCurrentScreenshot = (...a: any[]) => kwOf(a).empty ?? null;
|
||||
out.FileCurrentPage = () => this.host.filePage;
|
||||
out.FilePageName = (...a: any[]) => {
|
||||
const kw = kwOf(a), page = this.host.filePage;
|
||||
return page === "auto" ? kw.auto ?? a[0] ?? "Auto " : page === "quick" ? kw.quick ?? a[1] ?? "Quick " : page;
|
||||
};
|
||||
out.FileSlotName = (slot: any, perPage: any) => {
|
||||
const n = Number(slot) - 1, size = Number(perPage) || 1;
|
||||
const page = Math.floor(n / size) + 1;
|
||||
return `${page}-${(n % size) + 1}`;
|
||||
};
|
||||
out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null);
|
||||
out.MixerValue = (mixer: any) => {
|
||||
return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v));
|
||||
@@ -1589,6 +1659,9 @@ export class ScreenManager {
|
||||
{ ...properties, idle: image, insensitive: image, sensitive: enabled, action }),
|
||||
});
|
||||
out.Fixed = shape("composite");
|
||||
out.Composite = shape("composite");
|
||||
out.LiveComposite = shape("composite");
|
||||
out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn
|
||||
out.AlphaMask = shape("alphamask");
|
||||
out.TrackCursor = shape("trackcursor");
|
||||
out.At = (child: any) => child;
|
||||
|
||||
Reference in new issue
Block a user