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..042813fae8 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,150 @@ const qPop = () => { } 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) + +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( + previousState: Arkham.Game, + gameState: Arkham.Game, + locationId: string, +): string | null { + const location = gameState.locations[locationId] + const previousLocation = previousState.locations[locationId] + if (!location || !previousLocation) return null + if (location.enemyLocation) return cardCodeImage(location.cardCode) + + 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) { + 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 = 0 + const cropY = 0 + const cropWidth = 423 + const cropHeight = 270 + 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', { willReadFrequently: true }) + 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(previous, 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 + }, 3200) + }) +} function entitiesMoved(previous: Arkham.Game, current: Arkham.Game) { const placementChanged = ( @@ -727,19 +872,44 @@ 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 + if (previousGame) { + if (suppressNextFlipPreview) { + suppressNextFlipPreview = false + } else { + showLocationFlipPreview(previousGame, nextGame) + } + } const apply = async () => { game.value = nextGame 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() } @@ -1513,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) { @@ -1531,6 +1702,7 @@ async function undoScenario() { gameCard.value = null tarotCards.value = [] uiLock.value = false + suppressNextFlipPreview = true undoScenarioChoice(props.gameId) } @@ -1544,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) { @@ -1835,6 +2008,7 @@ onMounted(() => { onBeforeRouteLeave(() => close()) onUnmounted(() => { + clearLocationFlipPreview() document.removeEventListener('keydown', handleKeyPress) document.removeEventListener('mousemove', onMove) focusLightObserver?.disconnect() @@ -1890,6 +2064,19 @@ onUnmounted(() => { /> + + +