diff --git a/assets/sprite-packs/demogorgon/manifest.json b/assets/sprite-packs/demogorgon/manifest.json new file mode 100644 index 0000000..a32e656 --- /dev/null +++ b/assets/sprite-packs/demogorgon/manifest.json @@ -0,0 +1,35 @@ +{ + "id": "demogorgon", + "version": "1", + "image": "sprite.png", + "frame_width": 512, + "frame_height": 512, + "animations": [ + { + "name": "idle", + "fps": 6, + "frames": [0, 1] + }, + { + "name": "active", + "fps": 10, + "frames": [1, 0] + }, + { + "name": "success", + "fps": 10, + "frames": [0, 1, 0], + "one_shot": true + }, + { + "name": "error", + "fps": 8, + "frames": [1, 0, 1], + "one_shot": true + } + ], + "anchor": { + "x": 0.5, + "y": 1.0 + } +} diff --git a/assets/sprite-packs/demogorgon/sprite.png b/assets/sprite-packs/demogorgon/sprite.png new file mode 100644 index 0000000..e5f3c9f Binary files /dev/null and b/assets/sprite-packs/demogorgon/sprite.png differ diff --git a/crates/sprimo-tauri/capabilities/default.json b/crates/sprimo-tauri/capabilities/default.json index 58d1063..8470a3c 100644 --- a/crates/sprimo-tauri/capabilities/default.json +++ b/crates/sprimo-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-set-position", "core:window:allow-inner-size", "core:window:allow-outer-position", + "core:window:allow-current-monitor", "core:event:allow-listen", "core:event:allow-unlisten" ] diff --git a/crates/sprimo-tauri/gen/schemas/capabilities.json b/crates/sprimo-tauri/gen/schemas/capabilities.json index 9f6178b..afcd279 100644 --- a/crates/sprimo-tauri/gen/schemas/capabilities.json +++ b/crates/sprimo-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:window:allow-current-monitor","core:event:allow-listen","core:event:allow-unlisten"]}} \ No newline at end of file diff --git a/docs/RELEASE_TESTING.md b/docs/RELEASE_TESTING.md index 51781ca..46ce343 100644 --- a/docs/RELEASE_TESTING.md +++ b/docs/RELEASE_TESTING.md @@ -134,6 +134,14 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md` - run repeated character switch cycles (`default` <-> `ferris`) and move scale slider each cycle - ensure no runtime frontend exception is shown (debug overlay/console) - ensure no visible magenta fringe remains around sprite edges after chroma-key conversion +12. Verify packaged tauri scale anchoring and bounds: +- repeated scale changes resize around window center (no consistent bottom-right drift) +- window remains visible on the current monitor (no off-screen drift) +- no runtime scale-path exception appears (for example monitor lookup API errors) + - no runtime position-arg exceptions appear during scale (e.g. float passed to integer position API) + - at large scale values (>= 1.8), full sprite remains visible without clipping +13. Verify packaged tauri frontend freshness: +- confirm package run reflects latest `frontend/tauri-ui` changes (no stale embedded UI bundle) ### Packaged Mode (Required Once Tauri Packaging Exists) diff --git a/docs/TAURI_RUNTIME_TESTING.md b/docs/TAURI_RUNTIME_TESTING.md index 6d92fb2..353b6bb 100644 --- a/docs/TAURI_RUNTIME_TESTING.md +++ b/docs/TAURI_RUNTIME_TESTING.md @@ -114,6 +114,13 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` - scaling behavior remains responsive after each pack switch 8. Chroma-key quality check: - verify no visible magenta (`#FF00FF`) fringe remains around sprite edges in normal runtime view +9. Scale anchor and bounds check: +- repeated scale changes should keep window centered without directional drift +- window must remain within current monitor bounds during scale adjustments +- no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor` + API mismatch) +- verify window resize uses consistent coordinate units (no accumulated drift over 20 scale changes) +- no runtime command/type error from position updates (e.g. `set_position` expects integer coords) ## Settings Window Checklist diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index 79e0fb1..ca9b775 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -2,7 +2,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/window"; +import { + PhysicalPosition, + PhysicalSize, + currentMonitor, + getCurrentWindow, + monitorFromPoint +} from "@tauri-apps/api/window"; import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet"; import "./styles.css"; @@ -45,29 +51,58 @@ function fittedWindowSize( scale: number ): { width: number; height: number } { const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; - const width = Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE); - const height = Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE); + const width = Math.round(Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)); + const height = Math.round(Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)); return { width, height }; } async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise { const window = getCurrentWindow(); const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]); - const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale); - const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON; - const heightChanged = Math.abs(target.height - innerSize.height) > SIZE_EPSILON; + const centerX = outerPosition.x + innerSize.width / 2; + const centerY = outerPosition.y + innerSize.height / 2; + let targetWidth = target.width; + let targetHeight = target.height; + let targetX = centerX - targetWidth / 2; + let targetY = centerY - targetHeight / 2; + let monitor: + | { + position: { x: number; y: number }; + size: { width: number; height: number }; + workArea: { position: { x: number; y: number }; size: { width: number; height: number } }; + } + | null = null; + + try { + monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor()); + } catch { + monitor = null; + } + + if (monitor !== null) { + targetWidth = Math.min(targetWidth, monitor.workArea.size.width); + targetHeight = Math.min(targetHeight, monitor.workArea.size.height); + targetX = centerX - targetWidth / 2; + targetY = centerY - targetHeight / 2; + } + + const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON; + const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON; if (!widthChanged && !heightChanged) { return; } - const deltaWidth = target.width - innerSize.width; - const deltaHeight = target.height - innerSize.height; - const targetX = outerPosition.x - deltaWidth / 2; - const targetY = outerPosition.y - deltaHeight; - - await window.setSize(new LogicalSize(target.width, target.height)); - await window.setPosition(new LogicalPosition(targetX, targetY)); + await window.setSize(new PhysicalSize(targetWidth, targetHeight)); + if (monitor !== null) { + const minX = Math.round(monitor.workArea.position.x); + const minY = Math.round(monitor.workArea.position.y); + const maxX = Math.round(monitor.workArea.position.x + monitor.workArea.size.width - targetWidth); + const maxY = Math.round(monitor.workArea.position.y + monitor.workArea.size.height - targetHeight); + targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX); + targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY); + } + await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY))); } function MainOverlayWindow(): JSX.Element { diff --git a/frontend/tauri-ui/src/renderer/pixi_pet.ts b/frontend/tauri-ui/src/renderer/pixi_pet.ts index cc115e9..399c23f 100644 --- a/frontend/tauri-ui/src/renderer/pixi_pet.ts +++ b/frontend/tauri-ui/src/renderer/pixi_pet.ts @@ -33,6 +33,22 @@ export type UiSnapshot = { }; type AnimationMap = Map; +const KEY_R = 0xff; +const KEY_G = 0x00; +const KEY_B = 0xff; +const FALLBACK_MIN_CONNECTED_RATIO = 0.005; +const CONNECTED_HUE_MIN = 270; +const CONNECTED_HUE_MAX = 350; +const CONNECTED_SAT_MIN = 0.25; +const CONNECTED_VAL_MIN = 0.08; +const FALLBACK_HUE_MIN = 255; +const FALLBACK_HUE_MAX = 355; +const FALLBACK_SAT_MIN = 0.15; +const FALLBACK_VAL_MIN = 0.04; +const HALO_HUE_MIN = 245; +const HALO_HUE_MAX = 355; +const HALO_SAT_MIN = 0.15; +const HALO_VAL_MIN = 0.04; export class PixiPetRenderer { private app: Application; @@ -101,28 +117,144 @@ export class PixiPetRenderer { ctx.drawImage(image, 0, 0); const frame = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = frame.data; - const keyR = 0xff; - const keyG = 0x00; - const keyB = 0xff; - const hardTolerance = 22; - const softTolerance = 46; - for (let i = 0; i < data.length; i += 4) { - const dr = Math.abs(data[i] - keyR); - const dg = Math.abs(data[i + 1] - keyG); - const db = Math.abs(data[i + 2] - keyB); - const maxDistance = Math.max(dr, dg, db); - if (maxDistance <= hardTolerance) { - data[i + 3] = 0; + const width = canvas.width; + const height = canvas.height; + const pixelCount = width * height; + const isKeyLike = new Uint8Array(pixelCount); + const removedBg = new Uint8Array(pixelCount); + const queue = new Int32Array(pixelCount); + let head = 0; + let tail = 0; + + const indexFor = (x: number, y: number): number => y * width + x; + const channelOffset = (index: number): number => index * 4; + const enqueueIfKeyLike = (x: number, y: number): void => { + const idx = indexFor(x, y); + if (isKeyLike[idx] === 1 && removedBg[idx] === 0) { + removedBg[idx] = 1; + queue[tail] = idx; + tail += 1; + } + }; + + for (let idx = 0; idx < pixelCount; idx += 1) { + const offset = channelOffset(idx); + const [h, s, v] = PixiPetRenderer.rgbToHsv( + data[offset], + data[offset + 1], + data[offset + 2] + ); + if ( + PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) && + s >= CONNECTED_SAT_MIN && + v >= CONNECTED_VAL_MIN + ) { + isKeyLike[idx] = 1; + } + } + + for (let x = 0; x < width; x += 1) { + enqueueIfKeyLike(x, 0); + enqueueIfKeyLike(x, height - 1); + } + for (let y = 1; y < height - 1; y += 1) { + enqueueIfKeyLike(0, y); + enqueueIfKeyLike(width - 1, y); + } + + while (head < tail) { + const idx = queue[head]; + head += 1; + const x = idx % width; + const y = Math.floor(idx / width); + if (x > 0) { + enqueueIfKeyLike(x - 1, y); + } + if (x + 1 < width) { + enqueueIfKeyLike(x + 1, y); + } + if (y > 0) { + enqueueIfKeyLike(x, y - 1); + } + if (y + 1 < height) { + enqueueIfKeyLike(x, y + 1); + } + } + + const connectedRemovedCount = tail; + for (let idx = 0; idx < pixelCount; idx += 1) { + if (removedBg[idx] !== 1) { continue; } - if (maxDistance <= softTolerance) { - const alphaScale = - (maxDistance - hardTolerance) / (softTolerance - hardTolerance); - const suppress = 1 - alphaScale; - data[i + 3] = Math.round(data[i + 3] * alphaScale); - // Remove magenta spill from antialiased edges after alpha reduction. - data[i] = Math.round(data[i] * (1 - 0.4 * suppress)); - data[i + 2] = Math.round(data[i + 2] * (1 - 0.4 * suppress)); + const offset = channelOffset(idx); + data[offset + 3] = 0; + } + + const needsFallback = + connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO; + if (needsFallback) { + for (let idx = 0; idx < pixelCount; idx += 1) { + const offset = channelOffset(idx); + const [h, s, v] = PixiPetRenderer.rgbToHsv( + data[offset], + data[offset + 1], + data[offset + 2] + ); + const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance( + data[offset], + data[offset + 1], + data[offset + 2], + KEY_R, + KEY_G, + KEY_B + ); + if ( + (PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) && + s >= FALLBACK_SAT_MIN && + v >= FALLBACK_VAL_MIN) || + maxDistanceFromHardKey <= 96 + ) { + data[offset + 3] = 0; + } + } + } + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const idx = indexFor(x, y); + if (data[channelOffset(idx) + 3] === 0) { + continue; + } + let touchesBackground = false; + if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) { + touchesBackground = true; + } else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) { + touchesBackground = true; + } else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) { + touchesBackground = true; + } else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) { + touchesBackground = true; + } + if (!touchesBackground) { + continue; + } + + const offset = channelOffset(idx); + const [h, s, v] = PixiPetRenderer.rgbToHsv( + data[offset], + data[offset + 1], + data[offset + 2] + ); + if ( + !PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) || + s < HALO_SAT_MIN || + v < HALO_VAL_MIN + ) { + continue; + } + data[offset] = Math.round(data[offset] * 0.72); + data[offset + 2] = Math.round(data[offset + 2] * 0.72); + data[offset + 3] = Math.round(data[offset + 3] * 0.86); } } ctx.putImageData(frame, 0, 0); @@ -135,6 +267,53 @@ export class PixiPetRenderer { }); } + private static maxColorDistance( + r: number, + g: number, + b: number, + keyR: number, + keyG: number, + keyB: number + ): number { + const dr = Math.abs(r - keyR); + const dg = Math.abs(g - keyG); + const db = Math.abs(b - keyB); + return Math.max(dr, dg, db); + } + + private static rgbToHsv(r: number, g: number, b: number): [number, number, number] { + const rf = r / 255; + const gf = g / 255; + const bf = b / 255; + const max = Math.max(rf, gf, bf); + const min = Math.min(rf, gf, bf); + const delta = max - min; + + let hue = 0; + if (delta > 0) { + if (max === rf) { + hue = 60 * (((gf - bf) / delta) % 6); + } else if (max === gf) { + hue = 60 * ((bf - rf) / delta + 2); + } else { + hue = 60 * ((rf - gf) / delta + 4); + } + } + if (hue < 0) { + hue += 360; + } + const saturation = max === 0 ? 0 : delta / max; + const value = max; + return [hue, saturation, value]; + } + + private static isHueInRange(hue: number, min: number, max: number): boolean { + if (min <= max) { + return hue >= min && hue <= max; + } + return hue >= min || hue <= max; + } + dispose(): void { if (this.disposed) { return; diff --git a/issues/issue4.md b/issues/issue4.md index 57a0acb..3580686 100644 --- a/issues/issue4.md +++ b/issues/issue4.md @@ -16,6 +16,8 @@ P1 - `issues/screenshots/issue4.png` - `issues/screenshots/issue4-b.png` - `issues/screenshots/issue4-c.png` + - `issues/screenshots/issue4-after-fix2-2026-02-14-145819.png` + - `issues/screenshots/issue4-after-fix4-2026-02-14-153233.png` ## Summary @@ -57,6 +59,17 @@ and scaling becomes ineffective after the error. the view in a broken/cropped state on creation failures. 5. Chroma-key conversion tolerance removed most `#FF00FF` background but still left magenta fringe on anti-aliased edges. +6. Scale fit used repeated position deltas and caused directional drift during repeated resizing. +7. API mismatch in tauri window module: +- runtime used `getCurrentWindow().currentMonitor()` but this API version exposes monitor lookup as + module function (`currentMonitor`), causing `TypeError` and skipping window fit. +8. Scale position math mixed physical window metrics (`outerPosition`/`innerSize`) with logical + set operations (`LogicalSize`/`LogicalPosition`), reintroducing cumulative drift in some DPI + contexts. +9. Ferris background keying needed adaptive key detection; fixed `#FF00FF` assumptions were still + too brittle for packaged atlas variants. +10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering + runtime arg errors (`expected i32`) and bypassing window fit updates. ## Fix Plan @@ -93,6 +106,32 @@ Implemented: - Made pack reload transactional (keep old renderer until new renderer creation succeeds). - Improved fit-window flow so scale apply continues after reload retries. - Added targeted diagnostics for reload failures. +6. `frontend/tauri-ui/src/main.tsx` +- Changed scaling anchor to window center and clamped resized window position within current + monitor bounds. +7. `frontend/tauri-ui/src/renderer/pixi_pet.ts` +- Replaced tolerance-only chroma key with border-connected `#FF00FF` background flood-fill removal + and localized edge halo suppression. +8. `crates/sprimo-tauri/capabilities/default.json` +- Added `core:window:allow-current-monitor` permission for monitor bounds clamping. +9. `frontend/tauri-ui/src/main.tsx` +- switched monitor lookup to module-level `currentMonitor()` with safe fallback so window scaling + still applies even if monitor introspection is unavailable. +10. `frontend/tauri-ui/src/renderer/pixi_pet.ts` +- added fallback global key cleanup when border-connected background detection is too sparse. +11. `frontend/tauri-ui/src/main.tsx` +- moved scale resizing and positioning to physical units (`PhysicalSize`/`PhysicalPosition`) and + monitor selection at window-center point (`monitorFromPoint`). +12. `frontend/tauri-ui/src/renderer/pixi_pet.ts` +- added adaptive border-derived key color selection with fallback key cleanup pass. +13. `scripts/package_windows.py` +- tauri packaging now explicitly rebuilds UI bundle to avoid stale embedded `dist` output. +14. `frontend/tauri-ui/src/main.tsx` +- enforced integer physical positioning and monitor work-area size clamping to prevent set-position + arg failures and large-scale clipping. +15. `frontend/tauri-ui/src/renderer/pixi_pet.ts` +- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background + removal and stronger fallback cleanup. ## Verification @@ -122,6 +161,14 @@ Implemented: - `2026-02-14 00:00` - codex - `Fix Implemented` - applied 8x7 generic splitter policy and pack-ID correction. - `2026-02-14 00:00` - reporter - `In Progress` - reported `issue4-after-fix1` still failing in packaged runtime. - `2026-02-14 00:00` - codex - `Fix Implemented` - hardened renderer reload/dispose and chroma-key edge cleanup. +- `2026-02-14 00:00` - reporter - `In Progress` - remaining magenta ferris edge + scale drift reported. +- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to border-connected chroma-key removal and center-anchored, monitor-clamped scale fit. +- `2026-02-14 00:00` - reporter - `In Progress` - reported `currentMonitor` TypeError and ferris magenta background still visible. +- `2026-02-14 00:00` - codex - `Fix Implemented` - corrected monitor API call and added fallback chroma cleanup pass. +- `2026-02-14 00:00` - reporter - `In Progress` - reported ferris magenta background still visible and scale drift recurrence. +- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement. +- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error. +- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy. ## Closure diff --git a/scripts/package_windows.py b/scripts/package_windows.py index bcdd735..ef83ae3 100644 --- a/scripts/package_windows.py +++ b/scripts/package_windows.py @@ -153,6 +153,8 @@ def sha256_file(path: Path) -> str: def package(frontend: FrontendLayout) -> PackageLayout: version = read_version() ensure_assets() + if frontend.id == "tauri": + run(["npm", "--prefix", "frontend/tauri-ui", "run", "build"]) binary = ensure_release_binary(frontend) runtime_files = ensure_runtime_files(frontend, binary.parent)