feat: player runtime for the Phone library and That New Teacher
CI / frontend (push) Failing after 17s
CI / backend (push) Failing after 34s

Named stores (PyNamespace: dotted access, functions that see their own store first, restored after a
save is loaded), real generators run step by step (next, send, yield as an expression), len/bool through
__len__/__bool__, os/calendar/itertools/pygame_sdl2 helpers, Skip and ToggleLocalVariable actions,
text tags {glitch} and {wave} and plain display of explode/gradient/ruby, SnowBlossom and anim.Blink,
RoundedCorners as a transform, transforms listed in `at`, ATL choice weights that are flags, and
renpy.image_size from the converter's picture sizes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 22:39:16 +03:00
1 parent 83cc7fdd5c
commit 11f87df712
15 files changed
+457 -24

No files matched your search

+54 -4
View File
@@ -45,6 +45,8 @@ export interface ScreenHost {
openUrl(url: string): void;
quickSave(): void;
quickLoad(): void;
/** The Skip action: fast-forward through text already seen. */
setSkipping?(on: boolean): void;
/** Ask the player to confirm (Confirm action). */
ask(text: string): Promise<boolean>;
/** The name of the current save page (`FilePageNameInputValue`). */
@@ -54,6 +56,8 @@ export interface ScreenHost {
saveSlot(slot: string, confirm: boolean): void;
loadSlot(slot: string): void;
deleteSlot(slot: string, confirm: boolean): void;
/** Natural size of a named picture (from the converter), or null. */
imageSize?(value: any): [number, number] | null;
/** Size of the game picture, for displayables that ask how much room they have. */
resolution?: [number, number];
/** The page of the save/load screen (`persistent._file_page`). */
@@ -174,6 +178,7 @@ export class ScreenManager {
py: this.host.py,
element: (value) => this.displayable(value),
area: () => this.host.resolution ?? [1920, 1080],
imageSize: (value) => this.host.imageSize?.(value) ?? null,
finish: (value) => this.interactionReturn?.(value),
}));
}
@@ -336,6 +341,8 @@ export class ScreenManager {
this.pending = requestAnimationFrame(() => {
this.pending = 0;
if (this.suspended) return;
// A press may have started after this frame was queued by a timer or hover.
if (this.pressed) { this.refreshAfterPress = true; return; }
for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst);
});
}
@@ -1147,6 +1154,25 @@ export class ScreenManager {
}
return child;
}
case "blink": { // anim.Blink(picture): fades in and out
const picture = this.displayable(a, depth + 1);
if (picture) picture.style.animation = "pg-blink 1.2s ease-in-out infinite";
return picture;
}
case "snowblossom": { // falling pictures
const field = document.createElement("div");
field.className = "pg-snow";
const count = Math.min(60, Number(value.kw.count ?? b ?? 10) || 10);
for (let i = 0; i < count; i++) {
const piece = this.displayable(a, depth + 1);
if (!piece) break;
piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`;
piece.style.animationDuration = `${6 + (i * 7) % 9}s`;
piece.style.animationDelay = `${-((i * 13) % 11)}s`;
field.append(piece);
}
return field;
}
case "conditionswitch": { // ConditionSwitch("cond", picture, ...): the first condition that holds
for (let i = 0; i + 1 < value.args.length; i += 2) {
const holds = typeof value.args[i] === "string" ? truthy(this.host.py.eval(parseExpression(value.args[i]))) : truthy(value.args[i]);
@@ -1546,11 +1572,16 @@ export class ScreenManager {
const named = (value: any): AtSpec => {
const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value
: value && typeof value.__transform === "string" ? value.__transform : "";
if (value && value.__rounded !== undefined) return { props: { rounded: value.__rounded } };
return name ? { name, atl: this.host.atl(name) ? name : undefined } : {};
};
const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else)
: spec.dyn ? named(this.ev(spec.dyn)) : spec);
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : [];
const pick = (spec: AtSpec): AtSpec[] => {
if (spec.choose) return pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else);
if (!spec.dyn) return [spec];
const value = this.ev(spec.dyn);
return (Array.isArray(value) ? value : [value]).filter((v) => v !== null && v !== undefined).map(named); // a list of transforms
};
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.flatMap(pick) : [];
const live: Props = {};
const apply = (p: Props) => {
const zoom = (p.zoom ?? 1);
@@ -1562,6 +1593,11 @@ 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 (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader)
const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`;
el.style.borderRadius = radius;
el.style.overflow = "hidden";
}
applyFilters(el, p);
};
for (const spec of specs) {
@@ -1744,6 +1780,13 @@ export class ScreenManager {
case "InvertSelected": this.perform(a, owner); return;
case "UploadSync":
case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return;
case "Skip": this.host.setSkipping?.(true); return;
case "Rollback": return; // there is no rollback in the browser player
case "ToggleLocalVariable": {
const now = owner?.vars.get(a);
owner?.vars.set(a, !truthy(now));
return;
}
case "Confirm": {
const yes = b, no = c;
void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); });
@@ -1844,7 +1887,7 @@ export class ScreenManager {
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
"FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync",
"DownloadSync", "Confirm"]) out[kind] = make(kind);
"DownloadSync", "Confirm", "Skip", "Rollback", "ToggleLocalVariable"]) out[kind] = make(kind);
// what an `input` edits: classes games can extend (`class Typing(VariableInputValue)` overrides set_text)
const inputBase = new PyClass("InputValue", [OBJECT]);
const native = (cls: PyClass, name: string, fn: (self: PyInstance, ...a: any[]) => any) => cls.attrs.set(name, nativeMethod(fn));
@@ -1961,6 +2004,13 @@ export class ScreenManager {
out.Text = shape("text");
out.RenpyText = shape("text");
out.Animation = shape("animation");
out.SnowBlossom = shape("snowblossom");
out.anim = { Blink: shape("blink") };
const rounded = (...a: any[]) => ({ __rounded: (a[a.length - 1]?.__kw ?? {}).radius ?? a[1] ?? 0 });
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.ConditionSwitch = shape("conditionswitch");
out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText`
out.MatrixColor = shape("matrixcolor");