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();
|
||||
|
||||
Reference in new issue
Block a user