From 454e3a1929b46b4b5ec1f3a7321b6d6861c8404e Mon Sep 17 00:00:00 2001 From: pilunte23 Date: Sun, 19 Jul 2026 19:36:19 +0200 Subject: [PATCH 1/4] animation for location --- .gitignore | 2 + frontend/src/arkham/views/Game.vue | 247 +++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/.gitignore b/.gitignore index 9f38f0ee70..3bc3714dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ ml/model/ # Local homebrew working data (guide transcriptions, codegen maps); the # checked-in guide is docs/homebrew.md docs/homebrew/ +frontend/public/* + diff --git a/frontend/src/arkham/views/Game.vue b/frontend/src/arkham/views/Game.vue index 3abc1a4791..7cffc2e173 100644 --- a/frontend/src/arkham/views/Game.vue +++ b/frontend/src/arkham/views/Game.vue @@ -61,6 +61,7 @@ import { useDebug } from '@/arkham/debug' import { useAi } from '@/arkham/ai' import { useSettings } from '@/stores/settings' import { cardImg, imgsrc } from '@/arkham/helpers' +import { cardImage as cardCodeImage } from '@/arkham/cardImages' import { handleEmbeddedI18n } from '@/arkham/i18n' import { getGameLocalStorageItem, setGameLocalStorageItem } from '@/arkham/localStorage' import * as Arkham from '@/arkham/types/Game' @@ -713,6 +714,132 @@ const qPop = () => { } let decoding = false let pendingUpdate: string | null = null +let locationFlipPreviewTimer: number | null = null +let locationFlipPreviewSequence = 0 + +const locationFlipPreview = ref<{ id: number; src: string; halo: string } | null>(null) + +function flippedLocation(previous: Arkham.Game, current: Arkham.Game): string | null { + for (const [id, location] of Object.entries(current.locations)) { + const previousLocation = previous.locations[id] + if (!previousLocation) continue + if (previousLocation.revealed !== location.revealed || previousLocation.cardCode !== location.cardCode) { + return id + } + } + + return null +} + +function locationImageUrl(gameState: Arkham.Game, locationId: string): string | null { + const location = gameState.locations[locationId] + if (!location) return null + if (location.enemyLocation) return cardCodeImage(location.cardCode) + return cardCodeImage(location.cardCode, location.revealed ? '' : 'b') +} + +function sharpenCanvas(context: CanvasRenderingContext2D, width: number, height: number) { + const imageData = context.getImageData(0, 0, width, height) + const input = imageData.data + const output = new Uint8ClampedArray(input) + const amount = 0.18 + + for (let y = 1; y < height - 1; y += 1) { + for (let x = 1; x < width - 1; x += 1) { + const idx = (y * width + x) * 4 + const top = idx - width * 4 + const bottom = idx + width * 4 + const left = idx - 4 + const right = idx + 4 + + for (let channel = 0; channel < 3; channel += 1) { + output[idx + channel] = input[idx + channel] * (1 + amount * 4) + - input[top + channel] * amount + - input[bottom + channel] * amount + - input[left + channel] * amount + - input[right + channel] * amount + } + } + } + + imageData.data.set(output) + context.putImageData(imageData, 0, 0) +} + +function cropLocationFlipImage(src: string): Promise<{ src: string; halo: string } | null> { + return new Promise((resolve) => { + const image = new Image() + image.crossOrigin = 'anonymous' + image.onload = () => { + const referenceWidth = 423 + const referenceHeight = 600 + const cropX = 8 + const cropY = 74 + const cropWidth = 407 + const cropHeight = 196 + const sourceScaleX = image.naturalWidth / referenceWidth + const sourceScaleY = image.naturalHeight / referenceHeight + const sourceX = cropX * sourceScaleX + const sourceY = cropY * sourceScaleY + const sourceWidth = Math.min(cropWidth * sourceScaleX, image.naturalWidth - sourceX) + const sourceHeight = Math.min(cropHeight * sourceScaleY, image.naturalHeight - sourceY) + if (sourceWidth <= 0 || sourceHeight <= 0) { + resolve(null) + return + } + + const canvas = document.createElement('canvas') + const scale = 3 + canvas.width = cropWidth * scale + canvas.height = cropHeight * scale + const context = canvas.getContext('2d') + if (!context) { + resolve(null) + return + } + + context.imageSmoothingEnabled = true + context.imageSmoothingQuality = 'high' + context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height) + + try { + const sample = context.getImageData(45 * scale, 30 * scale, 1, 1).data + const halo = `rgba(${sample[0]}, ${sample[1]}, ${sample[2]}, 0.62)` + sharpenCanvas(context, canvas.width, canvas.height) + resolve({ src: canvas.toDataURL('image/png'), halo }) + } catch (error) { + console.warn('Unable to create location flip preview', error) + resolve(null) + } + } + image.onerror = () => resolve(null) + image.src = src + }) +} + +function clearLocationFlipPreview() { + if (locationFlipPreviewTimer !== null) window.clearTimeout(locationFlipPreviewTimer) + locationFlipPreviewTimer = null + locationFlipPreview.value = null +} + +function showLocationFlipPreview(previous: Arkham.Game, current: Arkham.Game) { + const locationId = flippedLocation(previous, current) + if (!locationId) return + const url = locationImageUrl(current, locationId) + if (!url) return + + const sequence = ++locationFlipPreviewSequence + void cropLocationFlipImage(url).then((preview) => { + if (!preview || sequence !== locationFlipPreviewSequence) return + if (locationFlipPreviewTimer !== null) window.clearTimeout(locationFlipPreviewTimer) + locationFlipPreview.value = { id: sequence, ...preview } + locationFlipPreviewTimer = window.setTimeout(() => { + if (locationFlipPreview.value?.id === sequence) locationFlipPreview.value = null + locationFlipPreviewTimer = null + }, 1300) + }) +} function entitiesMoved(previous: Arkham.Game, current: Arkham.Game) { const placementChanged = ( @@ -730,6 +857,7 @@ function entitiesMoved(previous: Arkham.Game, current: Arkham.Game) { function applyGameUpdate(updatedGame: Arkham.Game, locked: boolean) { const nextGame = locked ? { ...updatedGame, question: {} } : updatedGame const previousGame = game.value + if (previousGame) showLocationFlipPreview(previousGame, nextGame) const apply = async () => { game.value = nextGame await nextTick() @@ -1835,6 +1963,7 @@ onMounted(() => { onBeforeRouteLeave(() => close()) onUnmounted(() => { + clearLocationFlipPreview() document.removeEventListener('keydown', handleKeyPress) document.removeEventListener('mousemove', onMove) focusLightObserver?.disconnect() @@ -1890,6 +2019,19 @@ onUnmounted(() => { /> + + +
Date: Mon, 20 Jul 2026 09:54:16 +0200 Subject: [PATCH 2/4] feat(frontend): enhance location image handling and animations --- frontend/src/arkham/views/Game.vue | 82 ++++++++++++++++++------------ 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/frontend/src/arkham/views/Game.vue b/frontend/src/arkham/views/Game.vue index 7cffc2e173..12d5133554 100644 --- a/frontend/src/arkham/views/Game.vue +++ b/frontend/src/arkham/views/Game.vue @@ -731,11 +731,25 @@ function flippedLocation(previous: Arkham.Game, current: Arkham.Game): string | return null } -function locationImageUrl(gameState: Arkham.Game, locationId: string): string | null { +function locationImageUrl( + previousState: Arkham.Game, + gameState: Arkham.Game, + locationId: string, +): string | null { const location = gameState.locations[locationId] - if (!location) return null + const previousLocation = previousState.locations[locationId] + if (!location || !previousLocation) return null if (location.enemyLocation) return cardCodeImage(location.cardCode) - return cardCodeImage(location.cardCode, location.revealed ? '' : 'b') + + const nextSide = location.revealed ? '' : 'b' + const previousSide = previousLocation.revealed ? '' : 'b' + if (location.revealed !== previousLocation.revealed) { + // Show the face reached after the flip. + return cardCodeImage(location.cardCode, nextSide) + } + + // If card face changed without reveal toggle, preview the opposite side. + return cardCodeImage(location.cardCode, previousSide) } function sharpenCanvas(context: CanvasRenderingContext2D, width: number, height: number) { @@ -773,10 +787,10 @@ function cropLocationFlipImage(src: string): Promise<{ src: string; halo: string image.onload = () => { const referenceWidth = 423 const referenceHeight = 600 - const cropX = 8 - const cropY = 74 - const cropWidth = 407 - const cropHeight = 196 + const cropX = 0 + const cropY = 0 + const cropWidth = 423 + const cropHeight = 270 const sourceScaleX = image.naturalWidth / referenceWidth const sourceScaleY = image.naturalHeight / referenceHeight const sourceX = cropX * sourceScaleX @@ -826,7 +840,7 @@ function clearLocationFlipPreview() { function showLocationFlipPreview(previous: Arkham.Game, current: Arkham.Game) { const locationId = flippedLocation(previous, current) if (!locationId) return - const url = locationImageUrl(current, locationId) + const url = locationImageUrl(previous, current, locationId) if (!url) return const sequence = ++locationFlipPreviewSequence @@ -837,7 +851,7 @@ function showLocationFlipPreview(previous: Arkham.Game, current: Arkham.Game) { locationFlipPreviewTimer = window.setTimeout(() => { if (locationFlipPreview.value?.id === sequence) locationFlipPreview.value = null locationFlipPreviewTimer = null - }, 1300) + }, 3200) }) } @@ -3694,15 +3708,16 @@ button:hover .shortcut { justify-content: center; pointer-events: none; background: - radial-gradient(ellipse at center, var(--location-flip-halo, rgba(255, 255, 255, 0.35)), rgba(8, 9, 10, 0) 42%), - radial-gradient(ellipse at center, rgba(8, 9, 10, 0.28), rgba(8, 9, 10, 0) 70%); + radial-gradient(ellipse at center, rgba(243, 230, 203, 0.36) 0%, rgba(243, 230, 203, 0.22) 32%, rgba(243, 230, 203, 0) 72%), + radial-gradient(ellipse at center, rgba(84, 49, 92, 0.16), rgba(84, 49, 92, 0) 68%); .location-flip-preview-frame { position: relative; - width: min(96vw, 1221px); - height: min(46.2vw, 588px); + width: min(92vw, 1269px); + aspect-ratio: 423 / 270; max-height: 86vh; border-radius: 8px; + overflow: hidden; filter: drop-shadow(0 0 3vmin var(--location-flip-halo, rgba(255, 255, 255, 0.45))) drop-shadow(0 5vmin 4vmin var(--location-flip-halo, rgba(255, 255, 255, 0.34))) @@ -3712,14 +3727,6 @@ button:hover .shortcut { 0 0 54px var(--location-flip-halo, rgba(255, 255, 255, 0.32)), 0 34px 110px rgba(0, 0, 0, 0.62); - &::before { - content: ''; - position: absolute; - inset: -18px; - border-radius: 18px; - background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0) 48%, var(--location-flip-halo, rgba(255, 255, 255, 0.34)) 74%, rgba(0, 0, 0, 0) 100%); - filter: blur(14px); - } } img { @@ -3729,17 +3736,28 @@ button:hover .shortcut { height: 100%; border-radius: inherit; filter: saturate(1.14) contrast(1.12) brightness(1.02); + /* Fade all 4 edges independently — each gradient masks one side */ + -webkit-mask-image: + linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%), + linear-gradient(to bottom, transparent 0%, black 8%, black 68%, transparent 100%); + -webkit-mask-composite: source-in; + mask-image: + linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%), + linear-gradient(to bottom, transparent 0%, black 8%, black 68%, transparent 100%); + mask-composite: intersect; } } -.location-flip-preview-enter-active, +.location-flip-preview-enter-active { + transition: opacity 0.6s ease; +} .location-flip-preview-leave-active { - transition: opacity 420ms ease; + transition: opacity 0.8s ease; } .location-flip-preview-enter-active .location-flip-preview-frame { animation: - location-flip-reveal 1300ms cubic-bezier(0.16, 1, 0.3, 1) both, + location-flip-reveal 4s cubic-bezier(0.16, 1, 0.3, 1) both, location-flip-glow 4s cubic-bezier(0.55, 0.085, 0.68, 0.53) infinite; } @@ -3751,18 +3769,18 @@ button:hover .shortcut { @keyframes location-flip-reveal { 0% { opacity: 0; - transform: translateY(10px); + transform: translateY(10px) scale(0.97); } - 24% { - opacity: 0.94; + 20% { + opacity: 1; } - 72% { - opacity: 0.9; - transform: translateY(0); + 80% { + opacity: 1; + transform: translateY(0) scale(1); } 100% { - opacity: 0; - transform: translateY(-4px); + opacity: 1; + transform: translateY(0) scale(1); } } From 155dc2c73d7a6136c52d46ee146f4209ebaffaba Mon Sep 17 00:00:00 2001 From: pilunte23 Date: Mon, 20 Jul 2026 12:55:22 +0200 Subject: [PATCH 3/4] feat(game): optimize image cropping and manage overlapping view transitions --- frontend/src/arkham/views/Game.vue | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/arkham/views/Game.vue b/frontend/src/arkham/views/Game.vue index 12d5133554..7e619ecaac 100644 --- a/frontend/src/arkham/views/Game.vue +++ b/frontend/src/arkham/views/Game.vue @@ -806,7 +806,7 @@ function cropLocationFlipImage(src: string): Promise<{ src: string; halo: string const scale = 3 canvas.width = cropWidth * scale canvas.height = cropHeight * scale - const context = canvas.getContext('2d') + const context = canvas.getContext('2d', { willReadFrequently: true }) if (!context) { resolve(null) return @@ -868,6 +868,9 @@ function entitiesMoved(previous: Arkham.Game, current: Arkham.Game) { || placementChanged(previous.enemies, current.enemies) } +type ViewTransitionLike = { skipTransition: () => void; finished: Promise } +let activeViewTransition: ViewTransitionLike | null = null + function applyGameUpdate(updatedGame: Arkham.Game, locked: boolean) { const nextGame = locked ? { ...updatedGame, question: {} } : updatedGame const previousGame = game.value @@ -877,11 +880,26 @@ function applyGameUpdate(updatedGame: Arkham.Game, locked: boolean) { await nextTick() } const transitionDocument = document as Document & { - startViewTransition?: (callback: () => Promise) => unknown + startViewTransition?: (callback: () => Promise) => ViewTransitionLike + } + + // Overlapping view transitions (a new game update arriving before the + // previous transition finished) can leave duplicate elements sharing the + // same view-transition-name in the snapshot, which the browser rejects + // with an uncaught "Unexpected duplicate view-transition-name" error. Skip + // any in-flight transition first so it settles immediately before starting + // the next one. + if (activeViewTransition) { + activeViewTransition.skipTransition() + activeViewTransition = null } if (previousGame && entitiesMoved(previousGame, nextGame) && transitionDocument.startViewTransition) { - transitionDocument.startViewTransition(apply) + const transition = transitionDocument.startViewTransition(apply) + activeViewTransition = transition + transition.finished.finally(() => { + if (activeViewTransition === transition) activeViewTransition = null + }) } else { void apply() } From dfaa20784e7b34f767dce5f3bb5194cf44b74734 Mon Sep 17 00:00:00 2001 From: pilunte23 Date: Mon, 20 Jul 2026 13:01:00 +0200 Subject: [PATCH 4/4] feat(game): implement suppressNextFlipPreview for undo actions to prevent location flip animations --- frontend/src/arkham/views/Game.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/arkham/views/Game.vue b/frontend/src/arkham/views/Game.vue index 7e619ecaac..042813fae8 100644 --- a/frontend/src/arkham/views/Game.vue +++ b/frontend/src/arkham/views/Game.vue @@ -716,6 +716,10 @@ let decoding = false let pendingUpdate: string | null = null let locationFlipPreviewTimer: number | null = null let locationFlipPreviewSequence = 0 +// Undo restores a previous game state, which looks like a location "flip" +// (or unflip) to the diffing logic below. Set this before issuing an undo +// request so the resulting update doesn't replay the flip preview animation. +let suppressNextFlipPreview = false const locationFlipPreview = ref<{ id: number; src: string; halo: string } | null>(null) @@ -874,7 +878,13 @@ let activeViewTransition: ViewTransitionLike | null = null function applyGameUpdate(updatedGame: Arkham.Game, locked: boolean) { const nextGame = locked ? { ...updatedGame, question: {} } : updatedGame const previousGame = game.value - if (previousGame) showLocationFlipPreview(previousGame, nextGame) + if (previousGame) { + if (suppressNextFlipPreview) { + suppressNextFlipPreview = false + } else { + showLocationFlipPreview(previousGame, nextGame) + } + } const apply = async () => { game.value = nextGame await nextTick() @@ -1673,6 +1683,7 @@ async function undo() { uiLock.value = false if (undoLock.value) return undoLock.value = true + suppressNextFlipPreview = true try { await undoChoice(props.gameId, debug.active) } catch (e) { @@ -1691,6 +1702,7 @@ async function undoScenario() { gameCard.value = null tarotCards.value = [] uiLock.value = false + suppressNextFlipPreview = true undoScenarioChoice(props.gameId) } @@ -1704,6 +1716,7 @@ async function undoBoundary(call: (gameId: string) => Promise) { tarotCards.value = [] uiLock.value = false undoLock.value = true + suppressNextFlipPreview = true try { await call(props.gameId) } catch (e) {