fix: a screen's video no longer flickers and its button stays clickable while a timer redraws the screen
CI / frontend (push) Failing after 16s
CI / backend (push) Failing after 33s

MIST's cutscene screen has a 0.1s timer that changes a variable, and every change rebuilt the whole
screen: the <video> restarted each time and "Skip" was replaced between press and release, so the
click never landed. Videos (Movie(play=...) and the movie channel picture) are now kept per shown
screen and resumed if the browser paused them, and a redraw requested while a pointer is down waits
for its release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:05:58 +03:00
1 parent 16ba20816c
commit 8251fca9bf
2 files changed
+93 -20

No files matched your search

+69 -19
View File
@@ -136,6 +136,34 @@ export class ScreenManager {
private styles: Record<string, StyleDef>) {
this.layer = document.createElement("div");
this.layer.className = "pg-usr";
// A click needs its button to stay in the page between press and release; a redraw in between would lose it
window.addEventListener("pointerdown", this.onPress, true);
window.addEventListener("pointerup", this.onRelease, true);
window.addEventListener("pointercancel", this.onRelease, true);
}
private pressed = false;
private refreshAfterPress = false;
private onPress = () => { this.pressed = true; };
private onRelease = () => {
this.pressed = false;
// after the click handlers of this release have run
window.setTimeout(() => { if (this.refreshAfterPress && !this.pressed) { this.refreshAfterPress = false; this.refresh(); } }, 0);
};
/** Videos survive a redraw of their screen: a new element would restart the picture. */
private movies = new Map<string, HTMLElement>();
private moviesSeen = new Set<string>();
private movieCount = new Map<string, number>();
private cachedMovie(key: string, make: () => HTMLElement): HTMLElement {
const n = (this.movieCount.get(key) ?? 0) + 1;
this.movieCount.set(key, n);
const id = `${this.rendering?.name ?? ""}|${key}|${n}`;
let element = this.movies.get(id);
if (!element) { element = make(); this.movies.set(id, element); }
this.moviesSeen.add(id);
return element;
}
has(name: string) {
@@ -212,6 +240,9 @@ export class ScreenManager {
}
destroy() {
window.removeEventListener("pointerdown", this.onPress, true);
window.removeEventListener("pointerup", this.onRelease, true);
window.removeEventListener("pointercancel", this.onRelease, true);
this.clear();
this.layer.remove();
}
@@ -238,12 +269,14 @@ export class ScreenManager {
inst.runners.forEach((r) => r.stop());
inst.runners = [];
inst.el.remove();
for (const id of [...this.movies.keys()]) if (id.startsWith(`${inst.name}|`)) this.movies.delete(id);
if (forget) this.shown.delete(inst.name);
}
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
refresh() {
if (this.suspended || this.pending || !this.shown.size) return;
if (this.pressed) { this.refreshAfterPress = true; return; }
this.pending = requestAnimationFrame(() => {
this.pending = 0;
if (this.suspended) return;
@@ -316,6 +349,8 @@ export class ScreenManager {
// --- rendering -----------------------------------------------------------------------
private render(inst: Instance) {
this.moviesSeen.clear();
this.movieCount.clear();
const previousScroll = [...inst.el.querySelectorAll<HTMLElement>(".pg-sviewport")].map((e) => e.scrollTop);
inst.timers.forEach((t) => window.clearTimeout(t));
inst.timers = [];
@@ -336,6 +371,16 @@ export class ScreenManager {
this.nodes(inst.def.body, ctx, children);
inst.el.replaceChildren(...children);
});
// a video taken out of the page pauses by itself; carry on, and forget the ones no screen shows now
for (const [id, element] of [...this.movies]) {
if (this.moviesSeen.has(id)) {
const video = element instanceof HTMLVideoElement ? element : element.querySelector("video");
if (video && video.paused && !video.ended) void video.play().catch(() => {});
} else if (id.startsWith(`${inst.name}|`)) {
if (element instanceof HTMLVideoElement) element.pause();
this.movies.delete(id);
}
}
for (const [node, clock] of [...inst.clocks]) {
if (!inst.clocksSeen.has(node)) {
window.clearTimeout(clock.handle);
@@ -811,6 +856,28 @@ export class ScreenManager {
// --- displayables --------------------------------------------------------------------
/** The picture of the channel that plays video; one element per shown screen (see cachedMovie). */
private channelMovie(value: Displayable): HTMLElement {
const box = document.createElement("div");
const size = value.kw.size;
box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%";
box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%";
const channel = String(value.kw.channel ?? "movie");
const sync = () => {
const video = this.host.audio.mediaOf(channel);
if (video instanceof HTMLVideoElement && video.parentElement !== box) {
video.style.width = video.style.height = "100%";
box.replaceChildren(video);
} else if (!video && box.firstChild) box.replaceChildren();
if (box.isConnected || !box.dataset.seen) {
if (box.isConnected) box.dataset.seen = "1";
window.setTimeout(sync, 200);
}
};
sync();
return box;
}
/** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */
private displayable(value: any, depth = 0): HTMLElement | null {
if (depth > 8 || value === null || value === undefined) return null;
@@ -896,27 +963,10 @@ export class ScreenManager {
const src = this.host.fileUrl(pyStr(value.kw.play ?? a));
const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null;
if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie");
return movieDisplayable(src, mask, value.kw.loop !== false);
return this.cachedMovie(`${src}|${mask}|${value.kw.loop !== false}`, () => movieDisplayable(src, mask, value.kw.loop !== false));
}
// the picture of the channel that plays video (renpy.music.play(..., channel="movie"))
const box = document.createElement("div");
const size = value.kw.size;
box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%";
box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%";
const channel = String(value.kw.channel ?? "movie");
const sync = () => {
const video = this.host.audio.mediaOf(channel);
if (video instanceof HTMLVideoElement && video.parentElement !== box) {
video.style.width = video.style.height = "100%";
box.replaceChildren(video);
} else if (!video && box.firstChild) box.replaceChildren();
if (box.isConnected || !box.dataset.seen) {
if (box.isConnected) box.dataset.seen = "1";
window.setTimeout(sync, 200);
}
};
sync();
return box;
return this.cachedMovie(`channel|${String(value.kw.channel ?? "movie")}|${JSON.stringify(value.kw.size ?? null)}`, () => this.channelMovie(value));
}
case "alphamask": {
const child = this.displayable(a, depth + 1);