import React, {useEffect, useState} from 'react'; import {getCharacterById} from '../../data/characterPresets'; import {METERS_TO_PIXELS} from '../../data/hostileNpcs'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { buildMedievalNpcVisual, buildMedievalNpcVisualFromCustomWorldVisual, } from '../../data/medievalNpcVisuals'; import { AnimationState, Character, CombatActionMode, CombatVisualEffect, CompanionRenderState, CustomWorldProfile, Encounter, SceneHostileNpc, ScenePresetInfo, StoryNpcAffinityEffect, StoryEngineMemoryState, WorldType, } from '../../types'; import {CharacterAnimator} from '../CharacterAnimator'; import {MedievalNpcAnimator} from '../MedievalNpcAnimator'; 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; customWorldProfile?: CustomWorldProfile | null; storyEngineMemory?: StoryEngineMemoryState | null; sceneHostileNpcs: 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; npcAffinityEffect?: StoryNpcAffinityEffect | 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 = { '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; const DEFAULT_IMAGE_STYLE: React.CSSProperties = { imageRendering: 'pixelated', objectPosition: 'center bottom', }; 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 HOSTILE_NPC_SCENE_INSET_PX = 28; export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18; export const CHAT_BUBBLE_SPRITE_SRC = '/chat.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 (
); } 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, sceneCombatants, playerCharacter, groundBottom, stageLiftPx, playerOffsetY, anchorOffsetY = 0, }: { origin: 'player' | 'hostile_npc' | 'monster'; hostileNpcId?: string; sceneCombatants: 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 ? sceneCombatants.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'; }) { if (character.visual) { return ( ); } return (
); } export function SceneEncounterNpcSprite({ encounter, state, facing, className, }: { encounter: Encounter; state: AnimationState; facing: 'left' | 'right'; className?: string; }) { const rawEncounterImageSrc = encounter.imageSrc?.trim() ?? ''; const { resolvedUrl: resolvedEncounterImageSrc, shouldResolve: shouldResolveEncounterImage, } = useResolvedAssetReadUrl(rawEncounterImageSrc); const displayEncounterImageSrc = resolvedEncounterImageSrc || (!shouldResolveEncounterImage ? rawEncounterImageSrc : ''); if (encounter.visual) { return ( ); } if (rawEncounterImageSrc && shouldResolveEncounterImage && !displayEncounterImageSrc) { return
; } if (displayEncounterImageSrc) { return ( {encounter.npcName} ); } const runtimeCustomWorldCharacter = encounter.characterId ? getCharacterById(encounter.characterId) : null; if (runtimeCustomWorldCharacter?.visual) { return ( ); } if (runtimeCustomWorldCharacter) { return (
); } return ( ); } 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 (
); } export function SceneEntityButton({ onClick, ariaLabel, className, style, children, }: { onClick?: (() => void) | null; ariaLabel?: string; className?: string; style?: React.CSSProperties; children: React.ReactNode; }) { if (!onClick) { return (
{children}
); } return ( ); }