初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,265 @@
import {motion} from 'motion/react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {Character, CombatVisualEffect, SceneHostileNpc} from '../../types';
import {getEntityEffectBottom} from './GameCanvasShared';
interface GameCanvasEffectLayerProps {
activeCombatEffects: CombatVisualEffect[];
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
sceneHostileNpcs: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
playerOffsetY: number;
stageRef: React.RefObject<HTMLDivElement | null>;
}
function useCombatEffectFrames(effect: CombatVisualEffect) {
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
setFrameIndex(0);
if (effect.frames.length <= 1) return;
const interval = window.setInterval(() => {
setFrameIndex(prev => Math.min(prev + 1, effect.frames.length - 1));
}, Math.max(50, Math.round(1000 / effect.fps)));
return () => window.clearInterval(interval);
}, [effect.fps, effect.frames, effect.id]);
return Math.min(frameIndex, Math.max(0, effect.frames.length - 1));
}
function TravelingSpriteCombatEffect({
effect,
startLeft,
endLeft,
startBottom,
endBottom,
stageRef,
}: {
effect: CombatVisualEffect;
startLeft: string;
endLeft: string;
startBottom: string;
endBottom: string;
stageRef: React.RefObject<HTMLDivElement | null>;
}) {
const frameIndex = useCombatEffectFrames(effect);
const startMarkerRef = useRef<HTMLDivElement>(null);
const endMarkerRef = useRef<HTMLDivElement>(null);
const [vector, setVector] = useState({x: 0, y: 0});
const [measured, setMeasured] = useState(false);
useLayoutEffect(() => {
setMeasured(false);
let cancelled = false;
const measure = () => {
const stage = stageRef.current;
const startEl = startMarkerRef.current;
const endEl = endMarkerRef.current;
if (cancelled) return;
if (!stage || !startEl || !endEl) {
setVector({x: 0, y: 0});
setMeasured(true);
return;
}
const stageRect = stage.getBoundingClientRect();
const startRect = startEl.getBoundingClientRect();
const endRect = endEl.getBoundingClientRect();
const startX = startRect.left + startRect.width / 2 - stageRect.left;
const startY = startRect.top + startRect.height / 2 - stageRect.top;
const endX = endRect.left + endRect.width / 2 - stageRect.left;
const endY = endRect.top + endRect.height / 2 - stageRect.top;
setVector({x: endX - startX, y: endY - startY});
setMeasured(true);
};
const frameId = requestAnimationFrame(() => {
requestAnimationFrame(measure);
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [effect.id, endBottom, endLeft, stageRef, startBottom, startLeft]);
const half = effect.sizePx / 2;
const markerBox: React.CSSProperties = {
position: 'absolute',
width: effect.sizePx,
height: effect.sizePx,
marginLeft: -half,
pointerEvents: 'none',
visibility: 'hidden',
zIndex: 0,
};
return (
<>
<div ref={startMarkerRef} aria-hidden style={{...markerBox, left: startLeft, bottom: startBottom}} />
<div ref={endMarkerRef} aria-hidden style={{...markerBox, left: endLeft, bottom: endBottom}} />
{measured && (
<motion.div
initial={{x: 0, y: 0, opacity: 0.98}}
animate={{x: vector.x, y: vector.y, opacity: [1, 1, 0.94]}}
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
className="pointer-events-none absolute"
style={{
left: startLeft,
bottom: startBottom,
width: `${effect.sizePx}px`,
height: `${effect.sizePx}px`,
zIndex: effect.zIndex ?? 24,
marginLeft: `-${half}px`,
}}
>
<img
src={effect.frames[frameIndex]}
alt=""
className="h-full w-full object-contain"
style={{
imageRendering: 'pixelated',
transform: effect.facing === 'left'
? `scaleX(-1) scale(${effect.scale ?? 1})`
: `scale(${effect.scale ?? 1})`,
}}
/>
</motion.div>
)}
</>
);
}
function SpriteCombatEffect({
effect,
startLeft,
endLeft,
startBottom,
endBottom,
}: {
effect: CombatVisualEffect;
startLeft: string;
endLeft?: string;
startBottom: string;
endBottom?: string;
}) {
const frameIndex = useCombatEffectFrames(effect);
return (
<motion.div
initial={{left: startLeft, bottom: startBottom, opacity: 0.98}}
animate={{
left: endLeft ?? startLeft,
bottom: endBottom ?? startBottom,
opacity: [1, 1, 0.94],
}}
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
className="pointer-events-none absolute"
style={{
width: `${effect.sizePx}px`,
height: `${effect.sizePx}px`,
zIndex: effect.zIndex ?? 24,
marginLeft: `-${effect.sizePx / 2}px`,
}}
>
<img
src={effect.frames[frameIndex]}
alt=""
className="h-full w-full object-contain"
style={{
imageRendering: 'pixelated',
transform: effect.facing === 'left'
? `scaleX(-1) scale(${effect.scale ?? 1})`
: `scale(${effect.scale ?? 1})`,
}}
/>
</motion.div>
);
}
export function GameCanvasEffectLayer({
activeCombatEffects,
getPlayerEffectLeft,
getHostileNpcEffectLeft,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
stageRef,
}: GameCanvasEffectLayerProps) {
return (
<>
{activeCombatEffects.map(effect => {
const startLeft = effect.startOrigin === 'player'
? getPlayerEffectLeft(effect.startX, effect.startOffsetX ?? 0)
: getHostileNpcEffectLeft(effect.startX, effect.startHostileNpcId ?? effect.startMonsterId, effect.startOffsetX ?? 0);
const endLeft = effect.endOrigin === 'player'
? getPlayerEffectLeft(effect.endX ?? effect.startX, effect.endOffsetX ?? effect.startOffsetX ?? 0)
: effect.endOrigin === 'hostile_npc' || effect.endOrigin === 'monster'
? getHostileNpcEffectLeft(effect.endX ?? effect.startX, effect.endHostileNpcId ?? effect.endMonsterId, effect.endOffsetX ?? effect.startOffsetX ?? 0)
: undefined;
const startBottom = `calc(${getEntityEffectBottom({
origin: effect.startOrigin,
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY: effect.startAnchorOffsetY ?? 0,
})} + ${effect.startYOffset}px)`;
const endBottom = `calc(${getEntityEffectBottom({
origin: effect.endOrigin ?? effect.startOrigin,
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY: effect.endAnchorOffsetY ?? effect.startAnchorOffsetY ?? 0,
})} + ${(effect.endYOffset ?? effect.startYOffset)}px)`;
const useTravelingPath = Boolean(
effect.traveling
&& endLeft
&& endBottom
&& (startLeft !== endLeft || startBottom !== endBottom),
);
if (useTravelingPath && endLeft && endBottom) {
return (
<TravelingSpriteCombatEffect
key={effect.id}
effect={effect}
startLeft={startLeft}
endLeft={endLeft}
startBottom={startBottom}
endBottom={endBottom}
stageRef={stageRef}
/>
);
}
return (
<SpriteCombatEffect
key={effect.id}
effect={effect}
startLeft={startLeft}
endLeft={endLeft}
startBottom={startBottom}
endBottom={endBottom}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,431 @@
import {motion} from 'motion/react';
import {getCharacterById} from '../../data/characterPresets';
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
import {RESOLVED_ENTITY_X_METERS} from '../../data/sceneEncounterPreviews';
import {
AnimationState,
type Character,
type CompanionRenderState,
type Encounter,
type SceneHostileNpc,
type ScenePresetInfo,
type WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getCharacterBottomOffsetPx,
getCharacterOpponentBottom,
getCompanionSlotOffset,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneEntityZIndex,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
ROLE_CHARACTER_FRAME_CLASS,
ROLE_CHARACTER_SPRITE_CLASS,
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
SceneEntityButton,
} from './GameCanvasShared';
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
interface GameCanvasEntityLayerProps {
companions: CompanionRenderState[];
currentScenePreset: ScenePresetInfo | null;
sceneTransitionToken: number;
isSceneTransitionEntering: boolean;
isSceneTransitionExiting: boolean;
transitionSweepPx: number;
sceneTransitionExitDurationS: number;
sceneTransitionEntryDurationS: number;
companionAnchorLeft: string;
companionAnchorBottom: string;
playerBottomOffsetPx: number;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
inBattle: boolean;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
playerLeft: string;
playerCharacter: Character | null;
playerHp: number;
playerMaxHp: number;
effectivePlayerFacing: 'left' | 'right';
effectivePlayerAnimationState: AnimationState;
shouldShowPlayerDialogueIcon: boolean;
dialogueIndicator?: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
sceneHostileNpcs: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
groundBottom: string;
stageLiftPx: number;
encounter: Encounter | null;
sideAnchor: string;
cameraAnchorX: number;
monsterAnchorMeters: number;
playerX: number;
}
export function GameCanvasEntityLayer({
companions,
currentScenePreset,
sceneTransitionToken,
isSceneTransitionEntering,
isSceneTransitionExiting,
transitionSweepPx,
sceneTransitionExitDurationS,
sceneTransitionEntryDurationS,
companionAnchorLeft,
companionAnchorBottom,
playerBottomOffsetPx,
sceneTransitionPhase,
inBattle,
onEntitySelect = null,
playerLeft,
playerCharacter,
playerHp,
playerMaxHp,
effectivePlayerFacing,
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
sceneHostileNpcs,
monsters,
getHostileNpcOuterLeft,
groundBottom,
stageLiftPx,
encounter,
sideAnchor,
cameraAnchorX,
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
return (
<motion.div
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
className="absolute"
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
transition={{
duration: isSceneTransitionExiting
? sceneTransitionExitDurationS
: isSceneTransitionEntering
? sceneTransitionEntryDurationS
: 0.18,
ease: 'linear',
delay: isSceneTransitionEntering
? (companion.slot === 'upper'
? SCENE_TRANSITION_UPPER_COMPANION_DELAY_S
: SCENE_TRANSITION_LOWER_COMPANION_DELAY_S)
: 0,
}}
style={{
left: companionAnchorLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<div className="relative">
<div
className="absolute"
style={{
left: `${slotOffset.left}px`,
bottom: `${slotOffset.bottom}px`,
transform: `translate(${companion.entryOffsetX ?? 0}px, ${companion.entryOffsetY ?? 0}px)`,
transition: companion.transitionMs
? `transform ${companion.transitionMs}ms linear`
: undefined,
}}
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
ariaLabel={`Inspect ${companion.character.name}`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<div
className="h-full w-full"
style={{
transform:
(sceneTransitionPhase === 'idle' ? companion.facing : 'right') === 'left'
? 'scaleX(-1)'
: undefined,
}}
>
<CharacterAnimator
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
className={`${ROLE_CHARACTER_SPRITE_CLASS} ${companion.hp <= 0 ? 'opacity-45 grayscale' : ''}`}
/>
</div>
</div>
</SceneEntityButton>
</div>
</div>
</motion.div>
);
})}
<motion.div
key={`player-${currentScenePreset?.id ?? 'none'}-${sceneTransitionToken}`}
className="absolute"
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
transition={{
duration: isSceneTransitionExiting
? sceneTransitionExitDurationS
: isSceneTransitionEntering
? sceneTransitionEntryDurationS
: 0.18,
ease: 'linear',
}}
style={{
left: playerLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<div className="relative">
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
</div>
)}
<SceneEntityButton
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
ariaLabel={playerCharacter ? `Inspect ${playerCharacter.name}` : undefined}
className="relative block"
>
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{playerCharacter && (
<CharacterAnimator
state={effectivePlayerAnimationState}
character={playerCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
)}
</div>
</div>
{shouldShowPlayerDialogueIcon && (
<div className="absolute -top-9 right-1">
<DialogueBubbleIcon
active={dialogueIndicator?.activeSpeaker === 'player'}
flip={effectivePlayerFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
</motion.div>
{sceneHostileNpcs.map(hostileNpc => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
const npcMonsterConfig = npcEncounter?.monsterPresetId
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0)}px)`;
const entityBottomOffsetPx = npcCharacter
? getCharacterBottomOffsetPx(stageLiftPx, npcCharacter, hostileNpc.yOffset ?? 0)
: stageLiftPx + (hostileNpc.yOffset ?? 0);
return (
<div
key={hostileNpc.id}
className="absolute"
style={{
left: getHostileNpcOuterLeft(hostileNpc),
bottom: entityBottom,
zIndex: getSceneEntityZIndex(entityBottomOffsetPx),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
ariaLabel={`Inspect ${hostileNpc.name}`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${npcCombatHpTop}px`}}
>
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{npcCharacter ? (
<RoleCharacterSprite
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
character={npcCharacter}
facing={npcSceneSpriteFacing}
/>
) : npcMonsterConfig ? (
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
<HostileNpcAnimator
hostileNpc={npcMonsterConfig}
animation={hostileNpc.animation}
flip={hostileNpc.facing === 'right'}
className="scale-[1.82] origin-bottom"
/>
</div>
) : (
<MedievalNpcAnimator
encounter={npcEncounter}
className="origin-bottom drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
facing={npcSceneSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon
active={dialogueIndicator.activeSpeaker === 'npc'}
flip={npcSceneSpriteFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
);
})}
{encounter &&
(() => {
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';
const peacefulAnchorX = isCampCompanionEncounter
? RESOLVED_ENTITY_X_METERS
: encounter.xMeters ?? monsterAnchorMeters;
const isPeacefulEncounterMoving =
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
const towardPeacefulPlayer = getFacingTowardPlayer(peacefulAnchorX, playerX);
const peacefulResolvedCharacter =
encounter.kind !== 'treasure' && encounter.characterId
? getCharacterById(encounter.characterId)
: null;
const peacefulMonsterConfig =
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
: stageLiftPx;
const peacefulNpcSpriteFacing =
encounter.kind === 'treasure' || peacefulResolvedCharacter
? towardPeacefulPlayer
: getRenderableNpcFacing(encounter, towardPeacefulPlayer, {medievalVisual: true});
return (
<div
className="absolute"
style={{
left: getMonsterWorldLeft(
sideAnchor,
peacefulAnchorX,
cameraAnchorX,
monsterAnchorMeters,
),
bottom: encounter.characterId
? getCharacterOpponentBottom(
groundBottom,
stageLiftPx,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx}px)`,
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
transition: isCampCompanionEncounter
? 'bottom 180ms ease'
: 'left 260ms linear, bottom 180ms ease',
}}
>
<SceneEntityButton
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
ariaLabel={encounter.kind === 'npc' ? `Inspect ${encounter.npcName}` : undefined}
className="relative flex w-28 flex-col items-center"
>
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{encounter.kind === 'treasure' ? (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
<img
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
alt={encounter.npcName}
className="h-12 w-12 object-contain"
style={{imageRendering: 'pixelated'}}
/>
</div>
) : peacefulResolvedCharacter ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
character={peacefulResolvedCharacter}
facing={peacefulNpcSpriteFacing}
/>
) : peacefulMonsterConfig ? (
<HostileNpcAnimator
hostileNpc={peacefulMonsterConfig}
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
flip={towardPeacefulPlayer === 'right'}
className="scale-[1.82] origin-bottom"
/>
) : (
<MedievalNpcAnimator
encounter={encounter}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
facing={peacefulNpcSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon
active={dialogueIndicator.activeSpeaker === 'npc'}
flip={peacefulNpcSpriteFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
);
})()}
</>
);
}

View File

@@ -0,0 +1,36 @@
import {motion} from 'motion/react';
interface GameCanvasOverlayLayerProps {
escapeLead: number;
}
export function GameCanvasOverlayLayer({escapeLead}: GameCanvasOverlayLayerProps) {
return (
<>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" />
{escapeLead > 0 && (
<>
<div
className="pointer-events-none absolute inset-0"
style={{
background: `linear-gradient(90deg, rgba(80, 180, 255, ${0.05 + escapeLead * 0.12}) 0%, rgba(0,0,0,0) 42%, rgba(0,0,0,0.18) 100%)`,
}}
/>
<motion.div
className="pointer-events-none absolute inset-x-0 top-4 text-center"
animate={{opacity: [0.45, 0.95, 0.45], scale: [1, 1.03, 1]}}
transition={{
duration: Math.max(0.5, 1.1 - escapeLead * 0.4),
repeat: Infinity,
ease: 'easeInOut',
}}
>
<span className="rounded-full border border-sky-300/30 bg-sky-950/65 px-3 py-1 text-[10px] font-bold tracking-[0.25em] text-sky-100">
{escapeLead > 0.72 ? 'Escaped pursuit' : 'Creating distance'}
</span>
</motion.div>
</>
)}
</>
);
}

View File

@@ -0,0 +1,208 @@
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
import {
type GameCanvasProps,
getCharacterBottomOffsetPx,
getMonsterWorldLeft,
getPlayerWorldLeft,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
} from './GameCanvasShared';
export function GameCanvasRuntime({
scrollWorld,
animationState,
playerCharacter,
encounter,
currentScenePreset,
worldType,
sceneHostileNpcs,
sceneMonsters,
playerX,
playerOffsetY,
playerFacing,
playerActionMode = 'idle',
inBattle,
playerHp,
playerMaxHp,
activeCombatEffects = [],
companions = [],
dialogueIndicator = null,
onEntitySelect = null,
onSceneNameClick = null,
sceneTransitionPhase = 'idle',
sceneTransitionToken = 0,
onSceneTransitionDurationsChange = null,
}: GameCanvasProps) {
const stageRef = useRef<HTMLDivElement>(null);
const [stageOuterWidth, setStageOuterWidth] = useState(0);
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
const backgroundSrc = currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const campSceneId = worldType ? getWorldCampScenePreset(worldType)?.id ?? null : null;
const showOpeningCampOverlay = Boolean(!inBattle && currentScenePreset?.id && currentScenePreset.id === campSceneId);
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';
const stageLiftPx = 68;
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
const resolvedSceneHostileNpcs = sceneHostileNpcs ?? sceneMonsters ?? [];
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
: Infinity;
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
const sideAnchor = '15%';
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
const companionAnchorBottom = `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px)`;
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
const playerLeft = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
: playerWorldLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof resolvedSceneHostileNpcs)[number]) =>
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
: getPlayerWorldLeft(sideAnchor, effectX, cameraAnchorX);
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
};
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
const effectHostileNpc = hostileNpcId ? resolvedSceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
const base = effectHostileNpc
? getHostileNpcOuterLeft(effectHostileNpc)
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
};
const isSceneTransitionExiting = sceneTransitionPhase === 'exiting';
const isSceneTransitionEntering = sceneTransitionPhase === 'entering';
const effectivePlayerAnimationState = sceneTransitionPhase === 'idle' ? animationState : AnimationState.RUN;
const effectivePlayerFacing = sceneTransitionPhase === 'idle' ? playerFacing : 'right';
const shouldShowPlayerDialogueIcon =
Boolean(dialogueIndicator?.showPlayer)
&& sceneTransitionPhase === 'idle'
&& effectivePlayerAnimationState !== AnimationState.RUN;
const transitionSweepPx = Math.max(stageOuterWidth + SCENE_TRANSITION_SPRITE_CLEARANCE_PX, 320);
const sceneTransitionTravelDurationS = transitionSweepPx / SCENE_TRANSITION_SPEED_PX_PER_S;
const sceneTransitionExitDurationS = sceneTransitionTravelDurationS;
const sceneTransitionEntryDurationS = sceneTransitionTravelDurationS;
const sceneTransitionEntryTotalDurationS =
sceneTransitionEntryDurationS + SCENE_TRANSITION_LOWER_COMPANION_DELAY_S;
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const measure = () => setStageOuterWidth(stage.clientWidth);
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(stage);
return () => observer.disconnect();
}, []);
useEffect(() => {
setBackgroundLoadFailed(false);
}, [backgroundSrc]);
useEffect(() => {
onSceneTransitionDurationsChange?.({
exitMs: Math.round(sceneTransitionExitDurationS * 1000),
entryMs: Math.round(sceneTransitionEntryTotalDurationS * 1000),
});
}, [
onSceneTransitionDurationsChange,
sceneTransitionEntryTotalDurationS,
sceneTransitionExitDurationS,
]);
useEffect(() => {
const nextSceneTitle = currentScenePreset?.name ?? null;
const previousSceneTitle = previousSceneTitleRef.current;
if (nextSceneTitle && previousSceneTitle && previousSceneTitle !== nextSceneTitle) {
setSceneTitleSpinToken(current => current + 1);
}
previousSceneTitleRef.current = nextSceneTitle;
}, [currentScenePreset?.name]);
return (
<div ref={stageRef} className="relative h-full w-full overflow-hidden bg-black">
<GameCanvasSceneLayer
backgroundLoadFailed={backgroundLoadFailed}
backgroundSrc={backgroundSrc}
currentScenePreset={currentScenePreset}
resolvedWorldType={resolvedWorldType}
showOpeningCampOverlay={showOpeningCampOverlay}
sceneTitleSpinToken={sceneTitleSpinToken}
onSceneNameClick={onSceneNameClick}
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}
/>
<GameCanvasEntityLayer
companions={companions}
currentScenePreset={currentScenePreset}
sceneTransitionToken={sceneTransitionToken}
isSceneTransitionEntering={isSceneTransitionEntering}
isSceneTransitionExiting={isSceneTransitionExiting}
transitionSweepPx={transitionSweepPx}
sceneTransitionExitDurationS={sceneTransitionExitDurationS}
sceneTransitionEntryDurationS={sceneTransitionEntryDurationS}
companionAnchorLeft={companionAnchorLeft}
companionAnchorBottom={companionAnchorBottom}
playerBottomOffsetPx={playerBottomOffsetPx}
sceneTransitionPhase={sceneTransitionPhase}
inBattle={inBattle}
onEntitySelect={onEntitySelect}
playerLeft={playerLeft}
playerCharacter={playerCharacter}
playerHp={playerHp}
playerMaxHp={playerMaxHp}
effectivePlayerFacing={effectivePlayerFacing}
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
sceneHostileNpcs={resolvedSceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}
encounter={encounter}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={playerX}
/>
<GameCanvasEffectLayer
activeCombatEffects={activeCombatEffects}
getPlayerEffectLeft={getPlayerEffectLeft}
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
sceneHostileNpcs={resolvedSceneHostileNpcs}
playerCharacter={playerCharacter}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}
playerOffsetY={playerOffsetY}
stageRef={stageRef}
/>
<GameCanvasOverlayLayer escapeLead={escapeLead} />
</div>
);
}

View File

@@ -0,0 +1,126 @@
import {AnimatePresence, motion} from 'motion/react';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
import {
OPENING_CAMP_OVERLAY_SRC,
SCENE_TITLE_GEAR_FILTER,
} from './GameCanvasShared';
interface GameCanvasSceneLayerProps {
backgroundLoadFailed: boolean;
backgroundSrc: string;
currentScenePreset: ScenePresetInfo | null;
resolvedWorldType: WorldType | null;
showOpeningCampOverlay: boolean;
sceneTitleSpinToken: number;
onSceneNameClick?: (() => void) | null;
onBackgroundLoadError: () => void;
}
export function GameCanvasSceneLayer({
backgroundLoadFailed,
backgroundSrc,
currentScenePreset,
resolvedWorldType,
showOpeningCampOverlay,
sceneTitleSpinToken,
onSceneNameClick = null,
onBackgroundLoadError,
}: GameCanvasSceneLayerProps) {
return (
<>
{!backgroundLoadFailed ? (
<img
src={backgroundSrc}
alt={currentScenePreset?.name || 'Scene background'}
className="absolute inset-0 h-full w-full object-cover"
style={{imageRendering: 'pixelated'}}
onError={onBackgroundLoadError}
/>
) : (
<div
className="absolute inset-0"
style={{
background:
resolvedWorldType === WorldType.WUXIA
? 'linear-gradient(180deg, #d97706 0%, #451a03 100%)'
: resolvedWorldType === WorldType.XIANXIA
? 'linear-gradient(180deg, #1d4ed8 0%, #0f172a 100%)'
: 'linear-gradient(180deg, #0f766e 0%, #0b1120 100%)',
}}
/>
)}
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-overlay [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.14),transparent_20%),radial-gradient(circle_at_80%_30%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.06),transparent_22%)]" />
{showOpeningCampOverlay && (
<img
src={OPENING_CAMP_OVERLAY_SRC}
alt=""
aria-hidden="true"
className="pointer-events-none absolute bottom-[9%] left-1/2 z-[1] w-[min(92%,980px)] -translate-x-1/2 object-contain opacity-95"
style={{
imageRendering: 'pixelated',
filter: 'drop-shadow(0 12px 30px rgba(0, 0, 0, 0.42))',
}}
/>
)}
{currentScenePreset && (
<div className="absolute left-1/2 top-3 z-20 -translate-x-1/2">
<motion.div
key={`scene-title-gear-left-${sceneTitleSpinToken}`}
initial={{rotate: 0}}
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : -180}}
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
className="pointer-events-none absolute left-0 top-1/2 -translate-x-[46%] -translate-y-1/2"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[2.35rem] w-[2.35rem] opacity-95"
style={{filter: SCENE_TITLE_GEAR_FILTER}}
/>
</motion.div>
<motion.div
key={`scene-title-gear-right-${sceneTitleSpinToken}`}
initial={{rotate: 0}}
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : 180}}
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
className="pointer-events-none absolute right-0 top-1/2 translate-x-[46%] -translate-y-1/2"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[2.35rem] w-[2.35rem] opacity-95"
style={{filter: SCENE_TITLE_GEAR_FILTER}}
/>
</motion.div>
<button
type="button"
onClick={onSceneNameClick ?? undefined}
className="pixel-nine-slice pixel-pressable relative z-10 min-w-[168px] max-w-[min(68vw,320px)] text-center text-[11px] font-bold tracking-[0.18em] text-white"
style={getNineSliceStyle(UI_CHROME.sceneTitle, {paddingX: 16, paddingY: 4})}
>
<span className="block overflow-hidden" style={{perspective: '480px'}}>
<span className="relative block h-[1.1rem] overflow-hidden leading-[1.1rem]">
<AnimatePresence initial={false}>
<motion.span
key={currentScenePreset.name}
initial={{y: '115%', rotateX: -55, opacity: 0.15, filter: 'blur(1.4px)'}}
animate={{y: '0%', rotateX: 0, opacity: 1, filter: 'blur(0px)'}}
exit={{y: '-115%', rotateX: 55, opacity: 0.15, filter: 'blur(1.4px)'}}
transition={{duration: 0.82, ease: [0.22, 1, 0.36, 1]}}
className="absolute inset-0 flex items-center justify-center whitespace-nowrap"
>
{currentScenePreset.name}
</motion.span>
</AnimatePresence>
</span>
</span>
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,307 @@
import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {
AnimationState,
Character,
CombatActionMode,
CombatVisualEffect,
CompanionRenderState,
Encounter,
SceneHostileNpc,
ScenePresetInfo,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
export type GameCanvasEntitySelection =
| {kind: 'player'}
| {kind: 'companion'; companion: CompanionRenderState}
| {kind: 'npc'; encounter: Encounter; battleState?: SceneHostileNpc};
export interface GameCanvasProps {
scrollWorld: boolean;
animationState: AnimationState;
playerCharacter: Character | null;
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
sceneHostileNpcs?: SceneHostileNpc[];
sceneMonsters?: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;
playerFacing: 'left' | 'right';
playerActionMode?: CombatActionMode;
inBattle: boolean;
playerHp: number;
playerMaxHp: number;
playerMana?: number;
playerMaxMana?: number;
activeCombatEffects?: CombatVisualEffect[];
companions?: CompanionRenderState[];
npcStates?: unknown;
dialogueIndicator?: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
onSceneNameClick?: (() => void) | null;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
sceneTransitionToken?: number;
onSceneTransitionDurationsChange?: ((durations: {exitMs: number; entryMs: number}) => void) | null;
}
export const MONSTER_RENDER_OFFSETS: Record<string, {x: number; y: number}> = {
'monster-06': {x: -18, y: 14},
};
export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const GENERIC_NPC_SCENE_SCALE = 1.72;
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
export const CHAT_BUBBLE_FRAME_COUNT = 12;
export const CHAT_BUBBLE_ACTIVE_FRAMES = [0, 1, 2, 3, 4, 5];
export const CHAT_BUBBLE_INACTIVE_FRAMES = [6, 7, 8, 9, 10, 11];
export const SCENE_TITLE_GEAR_FILTER =
'sepia(1) saturate(2.1) hue-rotate(338deg) brightness(0.94) contrast(1.08) drop-shadow(0 6px 12px rgba(0, 0, 0, 0.42))';
export const SCENE_TRANSITION_SPRITE_CLEARANCE_PX = 168;
export const SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX = 400;
export const SCENE_TRANSITION_REFERENCE_DURATION_S = 5;
export const SCENE_TRANSITION_SPEED_PX_PER_S =
(SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX + SCENE_TRANSITION_SPRITE_CLEARANCE_PX)
/ SCENE_TRANSITION_REFERENCE_DURATION_S;
export const SCENE_TRANSITION_UPPER_COMPANION_DELAY_S = 0.43;
export const SCENE_TRANSITION_LOWER_COMPANION_DELAY_S = 0.93;
export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
return slot === 'upper'
? {left: -56, bottom: 66}
: {left: -34, bottom: 10};
}
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
if (animation === 'move') return AnimationState.RUN;
if (animation === 'attack') return AnimationState.ATTACK;
return AnimationState.IDLE;
}
export function HpBar({
hp,
maxHp,
tone,
}: {
hp: number;
maxHp: number;
tone: 'emerald' | 'rose';
}) {
const ratio = Math.max(0, Math.min(1, maxHp > 0 ? hp / maxHp : 0));
const fill = tone === 'emerald' ? 'from-emerald-400 to-green-300' : 'from-rose-500 to-red-400';
return (
<div className="w-11">
<div className="h-1 overflow-hidden rounded-full border border-white/10 bg-black/55 shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
<div className={`h-full bg-gradient-to-r ${fill}`} style={{width: `${ratio * 100}%`}} />
</div>
</div>
);
}
export function getPlayerWorldLeft(
sideAnchor: string,
playerX: number,
cameraAnchorX: number,
) {
return `calc(${sideAnchor} + ${(playerX - cameraAnchorX) * METERS_TO_PIXELS * 0.65}px)`;
}
export function getMonsterWorldLeft(
sideAnchor: string,
monsterX: number,
cameraAnchorX: number,
monsterAnchorMeters: number,
) {
return `calc(100% - ${sideAnchor} + ${((monsterX - cameraAnchorX) - monsterAnchorMeters) * METERS_TO_PIXELS * 0.75}px - ${ENTITY_CONTAINER_REM}rem)`;
}
export function getCharacterOpponentBottom(
groundBottom: string,
stageLiftPx: number,
character: Character | null | undefined,
) {
const groundOffset = character?.groundOffsetY ?? 22;
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
}
export function getSceneEntityZIndex(bottomOffsetPx: number) {
return Math.max(1, Math.min(9, 9 - Math.round(bottomOffsetPx / 16)));
}
export function getCharacterBottomOffsetPx(
stageLiftPx: number,
character: Character | null | undefined,
extraOffsetPx = 0,
) {
const groundOffset = character?.groundOffsetY ?? 22;
return stageLiftPx - groundOffset + extraOffsetPx;
}
export function getEntityEffectBottom({
origin,
hostileNpcId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY = 0,
}: {
origin: 'player' | 'hostile_npc' | 'monster';
hostileNpcId?: string;
sceneHostileNpcs: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
playerOffsetY: number;
anchorOffsetY?: number;
}) {
if (origin === 'player') {
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
return `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px + ${anchorOffsetY}px)`;
}
const targetHostileNpc = hostileNpcId
? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId)
: null;
if (!targetHostileNpc) {
return `calc(${groundBottom} + ${stageLiftPx}px + ${anchorOffsetY}px)`;
}
if (targetHostileNpc.encounter?.characterId) {
return getCharacterOpponentBottom(
groundBottom,
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
getCharacterById(targetHostileNpc.encounter.characterId),
);
}
const genericNpcTargetOffset =
targetHostileNpc.encounter
&& !targetHostileNpc.encounter.characterId
&& !targetHostileNpc.encounter.monsterPresetId
? GENERIC_NPC_EFFECT_TARGET_OFFSET_PX
: 0;
return `calc(${groundBottom} + ${stageLiftPx}px + ${((targetHostileNpc.yOffset ?? 0) + genericNpcTargetOffset + anchorOffsetY)}px)`;
}
export function RoleCharacterSprite({
character,
state,
facing,
}: {
character: Character;
state: AnimationState;
facing: 'left' | 'right';
}) {
return (
<div className="h-full w-full" style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}>
<CharacterAnimator
state={state}
character={character}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
</div>
);
}
export function DialogueBubbleIcon({
active = false,
flip = false,
}: {
active?: boolean;
flip?: boolean;
}) {
const frameSequence = active ? CHAT_BUBBLE_ACTIVE_FRAMES : CHAT_BUBBLE_INACTIVE_FRAMES;
const [frameCursor, setFrameCursor] = useState(0);
useEffect(() => {
setFrameCursor(0);
const interval = window.setInterval(() => {
setFrameCursor(prev => (prev + 1) % frameSequence.length);
}, active ? 120 : 180);
return () => window.clearInterval(interval);
}, [active, frameSequence.length]);
const frameIndex = frameSequence[frameCursor] ?? frameSequence[0] ?? 0;
return (
<div
className="pointer-events-none"
style={{
width: `${CHAT_BUBBLE_FRAME_WIDTH}px`,
height: `${CHAT_BUBBLE_FRAME_HEIGHT}px`,
backgroundImage: `url("${CHAT_BUBBLE_SPRITE_SRC}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${frameIndex * CHAT_BUBBLE_FRAME_WIDTH}px 0px`,
backgroundSize: `${CHAT_BUBBLE_FRAME_WIDTH * CHAT_BUBBLE_FRAME_COUNT}px ${CHAT_BUBBLE_FRAME_HEIGHT}px`,
imageRendering: 'pixelated',
transform: `${flip ? 'scaleX(-1) ' : ''}scale(${active ? 1.15 : 1})`,
transformOrigin: 'center',
filter: active
? 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.45))'
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.45))',
}}
/>
);
}
export function SceneEntityButton({
onClick,
ariaLabel,
className,
style,
children,
}: {
onClick?: (() => void) | null;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
if (!onClick) {
return (
<div className={className} style={style}>
{children}
</div>
);
}
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`group touch-manipulation transition-transform duration-150 hover:scale-[1.02] focus-visible:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${className ?? ''}`}
style={style}
>
{children}
</button>
);
}