diff --git a/frontend/src/player/audio.ts b/frontend/src/player/audio.ts index b75f75d..3b5aac6 100644 --- a/frontend/src/player/audio.ts +++ b/frontend/src/player/audio.ts @@ -147,6 +147,14 @@ export class AudioSystem { stop(ch: string, fadeout = 0) { const c = this.channels.get(ch); + // Rapid script advances can replace a track before its previous crossfade + // ends. Retire that older tail instead of accumulating music on one channel. + for (const [el, playback] of this.playing) { + if (playback.channel !== ch || el === c?.el) continue; + playback.cancelFade(); + el.pause(); + this.playing.delete(el); + } if (!c?.el) return; const el = c.el; c.el = null; diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index c997e4e..38bf77e 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -249,6 +249,7 @@ export class Engine { this.gen++; this.wakeAll(); this.audio.stopAll(); + this.renderer.reset(); this.resizeObserver.disconnect(); window.removeEventListener("keydown", this.onKey); window.removeEventListener("keyup", this.onKeyUp); @@ -1480,7 +1481,14 @@ export class Engine { private jump(target: string, args?: { a: any[]; kw: Record }, frame?: Frame) { if (this.gallerySession && isMistGallery(this.game) && target === "myroomcontrol") { - if (truthy(this.store.replaywatch)) target = "rewatchscene"; + if (truthy(this.store.replaywatch)) { + // Scene endings restore the room's HUD before this jump. The isolated + // gallery bypasses the room label, so retire that HUD and scene media here. + this.screens.clear(); + this.audio.stopAll(); + this.renderer.reset(); + target = "rewatchscene"; + } else { this.closeUnlockedGallery(); return; } } if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__"; @@ -2039,15 +2047,15 @@ export class Engine { } if (this.skip) return Promise.resolve(); return this.wait((done) => { - const timer = window.setTimeout(() => { - this.onAdvance = null; - done(); - }, seconds * 1000); - this.onAdvance = () => { + const finish = () => { clearTimeout(timer); this.onAdvance = null; + this.skipTick = null; done(); }; + const timer = window.setTimeout(finish, seconds * 1000); + this.onAdvance = finish; + this.skipTick = () => { if (this.skip) finish(); }; }); } diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 97079e6..3970f18 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -25,7 +25,7 @@ .pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; } .pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; } -.pg-layer { position: absolute; inset: 0; } +.pg-layer { position: absolute; inset: 0; pointer-events: none; } .pg-transition { pointer-events: none; } .pg-transition > .pg-old { position: absolute; inset: 0; background: #000; } .pg-fill { position: absolute; inset: 0; } diff --git a/frontend/src/player/render.ts b/frontend/src/player/render.ts index f539674..bad6a4b 100644 --- a/frontend/src/player/render.ts +++ b/frontend/src/player/render.ts @@ -239,6 +239,7 @@ export class RenderHost { private events(root: HTMLElement, value: PyInstance, started: number) { const handler = this.options.py.attrOf(value, "event"); if (typeof handler !== "function") return; + root.style.pointerEvents = "auto"; const send = (type: number, ev: PointerEvent, button = 0) => { const box = root.getBoundingClientRect(); const scale = box.width && root.offsetWidth ? root.offsetWidth / box.width : 1; diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index 21a7b23..e6a1747 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -43,6 +43,7 @@ export interface RendererHost { interface Entry extends ShownImage { el: HTMLElement; runners: { runner: AtlRunner; block: AtlBlock }[]; + resize?: ResizeObserver; } /** One of the game's layers (config.layers): its own pictures, its own transform (camera) and, if screens are shown on it, their container. */ @@ -166,6 +167,7 @@ export class Renderer { const layer = this.layers.get(layerName); if (!layer) return; for (const e of layer.shown.values()) { + e.resize?.disconnect(); e.runners.forEach((r) => r.runner.stop()); e.el.remove(); } @@ -192,6 +194,7 @@ export class Renderer { const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el, runners: [] }; prev?.runners.forEach((r) => r.runner.stop()); + prev?.resize?.disconnect(); if (prev) { prev.el.replaceWith(el); } else { @@ -201,6 +204,12 @@ export class Renderer { } layer.shown.set(tag, entry); this.style(el, { ...base }); // now that it is in the page it has a size + // Movies acquire their intrinsic dimensions after metadata loads. Animations + // can also change size when replacing a frame. Keep the current ATL position. + entry.resize = new ResizeObserver(() => { + if (el.isConnected && (el as any).__style) this.style(el, (el as any).__style); + }); + entry.resize.observe(el); this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline); } @@ -254,6 +263,7 @@ export class Renderer { const layer = this.layers.get(layerName); const e = layer?.shown.get(tag); if (!layer || !e) return; + e.resize?.disconnect(); layer.shown.delete(tag); const exit = e.runners.find((r) => r.runner.hasHideHandler(r.block)); if (!exit || this.fast) { diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 59ddc1c..73e4183 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -1400,6 +1400,19 @@ export class ScreenManager { const sensitive = props.sensitive ? truthy(this.ev(props.sensitive)) && this.actionSensitive(action) : this.actionSensitive(action); const selected = props.selected ? truthy(this.ev(props.selected)) : this.actionSelected(action); let hover = false; + // Keep every state in the same grid cell. Replacing the idle image with an + // unloaded hover image collapses the hit area and causes enter/leave loops. + const pictures = new Map(); + if (images) for (const file of Object.values(images)) { + if (!file || pictures.has(file)) continue; + const picture = this.displayable(file); + if (!picture) continue; + picture.classList.add("pg-simg"); + picture.style.gridArea = "1 / 1"; + picture.style.pointerEvents = "none"; + pictures.set(file, picture); + el.append(picture); + } const paint = () => { const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive") : selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle"; @@ -1409,10 +1422,7 @@ export class ScreenManager { } el.dataset.state = state; if (images) { - const file = images[state]; - const picture = file ? this.displayable(file) : null; - el.replaceChildren(...(picture ? [picture] : [])); - picture?.classList.add("pg-simg"); + for (const [file, picture] of pictures) picture.style.opacity = file === images[state] ? "1" : "0"; } else { this.look(el, props, state); if (label) this.textLook(label, { ...props, ...Object.fromEntries(Object.entries(textProps).map(([k, v]) => [k, v])) }, state); @@ -1741,7 +1751,9 @@ export class ScreenManager { /** Run what a widget's `action` evaluated to. */ run(action: any, screen: Instance | null) { - if (this.suspended) return; + // Absent hover actions must not rebuild the button under the pointer: + // its replacement would receive mouseenter again and replay hover_sound. + if (this.suspended || action === null || action === undefined || action === false) return; // actions act on the game, not on the screen's own variable scope const { py } = this.host; const savedFn = py.fn; diff --git a/frontend/tests/audio.test.mjs b/frontend/tests/audio.test.mjs index c9dd59d..1a3797f 100644 --- a/frontend/tests/audio.test.mjs +++ b/frontend/tests/audio.test.mjs @@ -93,6 +93,19 @@ test("outgoing tracks also obey the mixer during crossfade", () => { assert.equal(elements[0].paused, true); }); +test("rapid crossfades keep only the current track and its immediate predecessor", () => { + const audio = system(); + audio.play("sound", ["effect"], {}); + for (const file of ["one", "two", "three", "four"]) { + audio.play("music", [file], { fadeout: 2, fadein: 2 }); + tick(100); + } + assert.deepEqual(elements.filter(el => !el.paused).map(el => el.src), ["effect", "three", "four"]); + audio.stop("music"); + assert.deepEqual(elements.filter(el => !el.paused).map(el => el.src), ["effect"]); + assert.equal(frames.size, 0); +}); + test("stopping during fade-in cancels the old animation", () => { const audio = system(); audio.play("music", ["track"], { fadein: 2 }); diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index dca36ef..ae3f8e2 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -8,6 +8,18 @@ const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} }, modules: [{ labels: [{ name: "start", body: [{ op: "return" }] }], anchors: {} }] }); +test("enabling Skip interrupts an already running timed pause", async () => { + const { engine } = makeEngine(runtime()); + try { + const pause = engine.pause(120); + assert.equal(typeof engine.onAdvance, 'function'); + engine.setSkip(true); + await Promise.race([pause, new Promise((_, reject) => setTimeout(() => reject(new Error('Skip left the pause running')), 100))]); + assert.equal(engine.onAdvance, null); + assert.equal(engine.skipTick, null); + } finally { engine.destroy(); } +}); + test("runtime image declarations produce scaled thumbnails and survive saving", () => { const { engine } = makeEngine(runtime()); try { diff --git a/frontend/tests/mist-gallery-playback-browser.test.mjs b/frontend/tests/mist-gallery-playback-browser.test.mjs new file mode 100644 index 0000000..d0a4621 --- /dev/null +++ b/frontend/tests/mist-gallery-playback-browser.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { readFileSync, createReadStream, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { test } from "node:test"; +import { build } from "esbuild"; + +test("MIST gallery scene accepts Skip and menu clicks, keeps video centered and bounds overlapping music", { + skip: !process.env.PGAMES_PLAYWRIGHT_PATH || !process.env.PGAMES_MIST_RUNTIME || !process.env.PGAMES_MIST_MANIFEST || !process.env.PGAMES_MIST_BLOBS, +}, async () => { + const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href); + const runtime = readFileSync(process.env.PGAMES_MIST_RUNTIME); + const manifest = { ...JSON.parse(readFileSync(process.env.PGAMES_MIST_MANIFEST)).manifest, galleryUnlocked: true, versionId: 1 }; + const compiled = await build({ stdin: { contents: ` + import {Engine} from './player/engine'; + const r = await (await fetch('/runtime.json')).json(); + const manifest = await (await fetch('/manifest.json')).json(); + const tr = {lang: 'en', renpyName: null, str: s => s, line: (_, s) => s}; + window.audioCalls = []; + const e = window.engine = new Engine(document.querySelector('main'), r.game, manifest, r.modules, + {list: async () => [], get: async () => null, put: async () => {}, remove: async () => {}}, + 'mist-playback-browser', () => {}, tr, async () => tr); + e.settings.cps = 0; + e.renderer.transitionsOff = true; + e.audio.setVolumes({music: 0, sound: 0, voice: 0}); + e.audio.setMixer('ambiant', 0); + const play = e.audio.play.bind(e.audio); + e.audio.play = (...args) => { window.audioCalls.push(args); return play(...args); }; + e.openUnlockedGallery();`, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" }, + bundle: true, write: false, format: "esm", platform: "browser", logLevel: "silent" }); + const css = readFileSync(new URL("../src/player/player.css", import.meta.url)); + const server = createServer((req, res) => { + if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; } + if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; } + if (req.url === "/runtime.json") { res.setHeader("Content-Type", "application/json"); res.end(runtime); return; } + if (req.url === "/manifest.json") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(manifest)); return; } + const hash = req.url?.match(/^\/api\/versions\/1\/files\/([a-f0-9]{64}\.[a-z0-9]+)$/)?.[1]; + if (hash) { + const path = resolve(process.env.PGAMES_MIST_BLOBS, hash.slice(0, 2), hash.slice(2, 4), hash); + try { + const size = statSync(path).size; + const range = req.headers.range?.match(/^bytes=(\d+)-(\d*)$/); + const start = range ? Number(range[1]) : 0, end = range?.[2] ? Number(range[2]) : size - 1; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Type", hash.endsWith(".webm") ? "video/webm" : hash.endsWith(".opus") ? "audio/ogg" : hash.endsWith(".webp") ? "image/webp" : "application/octet-stream"); + res.setHeader("Content-Length", end - start + 1); + if (range) { res.statusCode = 206; res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`); } + createReadStream(path, { start, end }).pipe(res); + } catch { res.statusCode = 404; res.end(); } + return; + } + res.setHeader("Content-Type", "text/html"); + res.end('
'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + let browser; + try { + browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL }); + const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + page.setDefaultTimeout(7000); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForFunction(() => window.engine?.screens.shown.has('rewatchgirlselectscreen')); + await page.waitForFunction(() => [...document.querySelectorAll('.pg-simgbtn img')].every(el => el.complete && el.naturalWidth > 0)); + await page.locator(".pg-simgbtn").nth(1).click(); + await page.waitForFunction(() => window.engine.screens.shown.has('robgallery')); + await page.evaluate(() => window.engine.screens.shown.get('robgallery').el.dataset.test = 'robgallery'); + await page.waitForFunction(() => [...document.querySelectorAll('.pg-simgbtn img')].every(el => el.complete && el.naturalWidth > 0)); + await page.locator('[data-test="robgallery"] .pg-simgbtn').nth(1).click(); + await page.waitForFunction(() => !!window.engine.onAdvance && !window.engine.screens.shown.size); + await page.locator('[data-act="skip"]').click(); + await page.getByRole('button', { name: 'End Scene.', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Keep Looking.', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('.pg-image video, video.pg-image')?.videoWidth > 0); + const state = await page.evaluate(() => { + const stage = document.querySelector('.pg-stage').getBoundingClientRect(); + const video = document.querySelector('.pg-image video, video.pg-image').getBoundingClientRect(); + return { dx: video.x - stage.x, dy: video.y - stage.y, width: video.width, height: video.height, + stageWidth: stage.width, stageHeight: stage.height, + music: [...window.engine.audio.playing.values()].filter(p => p.channel === 'music').length, + calls: window.audioCalls.filter(call => call[0] === 'sound').map(call => call[1][0]) }; + }); + assert.ok(Math.abs(state.dx) < 1 && Math.abs(state.dy) < 1, `video drift: ${JSON.stringify(state)}`); + assert.ok(Math.abs(state.width - state.stageWidth) < 1 && Math.abs(state.height - state.stageHeight) < 1); + assert.ok(state.music <= 2, `overlapping music tracks: ${state.music}`); + assert.equal(new Set(state.calls).size, state.calls.length, "the scene's sounds are not replayed by duplicate interpreter loops"); + await page.locator('[data-act="skip"]').click(); + await page.getByRole('button', { name: 'End Scene.', exact: true }).click(); + await page.waitForFunction(() => window.engine.screens.shown.has('rewatchgirlselectscreen')); + const returned = await page.evaluate(() => ({ screens: [...window.engine.screens.shown.keys()], + audio: window.engine.audio.playing.size, images: window.engine.renderer.snapshot().length })); + assert.deepEqual(returned, { screens: ['rewatchgirlselectscreen'], audio: 0, images: 0 }); + } finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/frontend/tests/screens.test.mjs b/frontend/tests/screens.test.mjs index 8f6b7c8..a12c03a 100644 --- a/frontend/tests/screens.test.mjs +++ b/frontend/tests/screens.test.mjs @@ -332,3 +332,27 @@ test("a redraw queued before pointerdown preserves the Return button until click manager.destroy(); } }); + +test("hover keeps image-button geometry and does not redraw screens for absent hover actions", async () => { + const { manager, calls } = setup(); + const c = v => ({ k: 'const', v }); + manager.screens.hoverTest = { params: [], props: {}, defaults: [], body: [ + { w: 'imagebutton', args: [], body: [], props: { idle: c('idle.png'), hover: c('hover.png'), hover_sound: c('hover.ogg') } }, + ] }; + try { + manager.show('hoverTest'); + const button = manager.layer.querySelector('.pg-simgbtn'); + const images = [...button.querySelectorAll('img')]; + assert.equal(images.length, 2, 'both state images participate in layout before hovering'); + button.onmouseenter(); + await settle(); + assert.equal(manager.layer.querySelector('.pg-simgbtn'), button, 'no hover rebuild under the pointer'); + assert.deepEqual([...button.querySelectorAll('img')], images, 'images stay attached across state changes'); + assert.deepEqual(images.map(el => el.style.opacity), ['0', '1']); + assert.deepEqual(calls.play, [['sound', 'hover.ogg', false]]); + button.onmouseleave(); + await settle(); + assert.equal(manager.layer.querySelector('.pg-simgbtn'), button); + assert.deepEqual(images.map(el => el.style.opacity), ['1', '0']); + } finally { manager.destroy(); } +});