fix: a screen's video no longer flickers and its button stays clickable while a timer redraws the screen
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:
1 parent
16ba20816c
commit
8251fca9bf
2 files changed
+93
-20
No files matched your search
@@ -136,6 +136,34 @@ export class ScreenManager {
|
|||||||
private styles: Record<string, StyleDef>) {
|
private styles: Record<string, StyleDef>) {
|
||||||
this.layer = document.createElement("div");
|
this.layer = document.createElement("div");
|
||||||
this.layer.className = "pg-usr";
|
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) {
|
has(name: string) {
|
||||||
@@ -212,6 +240,9 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
window.removeEventListener("pointerdown", this.onPress, true);
|
||||||
|
window.removeEventListener("pointerup", this.onRelease, true);
|
||||||
|
window.removeEventListener("pointercancel", this.onRelease, true);
|
||||||
this.clear();
|
this.clear();
|
||||||
this.layer.remove();
|
this.layer.remove();
|
||||||
}
|
}
|
||||||
@@ -238,12 +269,14 @@ export class ScreenManager {
|
|||||||
inst.runners.forEach((r) => r.stop());
|
inst.runners.forEach((r) => r.stop());
|
||||||
inst.runners = [];
|
inst.runners = [];
|
||||||
inst.el.remove();
|
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);
|
if (forget) this.shown.delete(inst.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
|
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
|
||||||
refresh() {
|
refresh() {
|
||||||
if (this.suspended || this.pending || !this.shown.size) return;
|
if (this.suspended || this.pending || !this.shown.size) return;
|
||||||
|
if (this.pressed) { this.refreshAfterPress = true; return; }
|
||||||
this.pending = requestAnimationFrame(() => {
|
this.pending = requestAnimationFrame(() => {
|
||||||
this.pending = 0;
|
this.pending = 0;
|
||||||
if (this.suspended) return;
|
if (this.suspended) return;
|
||||||
@@ -316,6 +349,8 @@ export class ScreenManager {
|
|||||||
// --- rendering -----------------------------------------------------------------------
|
// --- rendering -----------------------------------------------------------------------
|
||||||
|
|
||||||
private render(inst: Instance) {
|
private render(inst: Instance) {
|
||||||
|
this.moviesSeen.clear();
|
||||||
|
this.movieCount.clear();
|
||||||
const previousScroll = [...inst.el.querySelectorAll<HTMLElement>(".pg-sviewport")].map((e) => e.scrollTop);
|
const previousScroll = [...inst.el.querySelectorAll<HTMLElement>(".pg-sviewport")].map((e) => e.scrollTop);
|
||||||
inst.timers.forEach((t) => window.clearTimeout(t));
|
inst.timers.forEach((t) => window.clearTimeout(t));
|
||||||
inst.timers = [];
|
inst.timers = [];
|
||||||
@@ -336,6 +371,16 @@ export class ScreenManager {
|
|||||||
this.nodes(inst.def.body, ctx, children);
|
this.nodes(inst.def.body, ctx, children);
|
||||||
inst.el.replaceChildren(...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]) {
|
for (const [node, clock] of [...inst.clocks]) {
|
||||||
if (!inst.clocksSeen.has(node)) {
|
if (!inst.clocksSeen.has(node)) {
|
||||||
window.clearTimeout(clock.handle);
|
window.clearTimeout(clock.handle);
|
||||||
@@ -811,6 +856,28 @@ export class ScreenManager {
|
|||||||
|
|
||||||
// --- displayables --------------------------------------------------------------------
|
// --- 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. */
|
/** 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 {
|
private displayable(value: any, depth = 0): HTMLElement | null {
|
||||||
if (depth > 8 || value === null || value === undefined) return 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 src = this.host.fileUrl(pyStr(value.kw.play ?? a));
|
||||||
const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null;
|
const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null;
|
||||||
if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie");
|
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"))
|
// the picture of the channel that plays video (renpy.music.play(..., channel="movie"))
|
||||||
const box = document.createElement("div");
|
return this.cachedMovie(`channel|${String(value.kw.channel ?? "movie")}|${JSON.stringify(value.kw.size ?? null)}`, () => this.channelMovie(value));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
case "alphamask": {
|
case "alphamask": {
|
||||||
const child = this.displayable(a, depth + 1);
|
const child = this.displayable(a, depth + 1);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseHTML } from "linkedom";
|
|||||||
|
|
||||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||||
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement,
|
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement,
|
||||||
HTMLImageElement: window.HTMLImageElement, Image: window.Image });
|
HTMLImageElement: window.HTMLImageElement, HTMLVideoElement: window.HTMLVideoElement ?? class {}, Image: window.Image });
|
||||||
window.setTimeout = setTimeout;
|
window.setTimeout = setTimeout;
|
||||||
window.clearTimeout = clearTimeout;
|
window.clearTimeout = clearTimeout;
|
||||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1);
|
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1);
|
||||||
@@ -271,3 +271,26 @@ test("an `if` that only sets properties picks the image; decompiled comma action
|
|||||||
assert.equal(store.mood_clicked, 1);
|
assert.equal(store.mood_clicked, 1);
|
||||||
assert.equal(store.mood_done, true);
|
assert.equal(store.mood_done, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a redraw keeps the playing video, and one held down button is not rebuilt under the pointer", async () => {
|
||||||
|
const movie = { w: "add", args: [{ k: "call", f: { k: "name", id: "Movie" }, a: [], kw: { play: { k: "const", v: "intro.webm" } } }],
|
||||||
|
props: {}, body: [] };
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.screens.cutscene = { params: [], props: {}, defaults: [], body: [movie] };
|
||||||
|
manager.show("cutscene", [], {});
|
||||||
|
const first = manager.layer.querySelector("video");
|
||||||
|
assert.ok(first, "the video is on the screen");
|
||||||
|
for (let i = 0; i < 3; i++) { manager.refresh(); await wait(30); }
|
||||||
|
assert.equal(manager.layer.querySelector("video"), first, "the same <video> after redraws");
|
||||||
|
manager.hide("cutscene");
|
||||||
|
manager.show("cutscene", [], {});
|
||||||
|
assert.notEqual(manager.layer.querySelector("video"), first, "a screen shown again starts a new video");
|
||||||
|
// a redraw requested while a pointer is down waits for its release
|
||||||
|
window.dispatchEvent(new window.Event("pointerdown"));
|
||||||
|
const during = manager.layer.querySelector("video");
|
||||||
|
manager.refresh();
|
||||||
|
await wait(30);
|
||||||
|
assert.equal(manager.layer.querySelector("video"), during);
|
||||||
|
window.dispatchEvent(new window.Event("pointerup"));
|
||||||
|
await wait(30);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user