429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
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<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;
|
|
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 (
|
|
<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,
|
|
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 (
|
|
<MedievalNpcAnimator
|
|
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
|
|
className="origin-bottom"
|
|
scale={1.36}
|
|
facing={facing}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 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 (
|
|
<MedievalNpcAnimator
|
|
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(encounter.visual)}
|
|
className={`origin-bottom ${className ?? ''}`.trim()}
|
|
scale={1.36}
|
|
facing={facing}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (rawEncounterImageSrc && shouldResolveEncounterImage && !displayEncounterImageSrc) {
|
|
return <div className={`h-full w-full ${className ?? ''}`.trim()} />;
|
|
}
|
|
|
|
if (displayEncounterImageSrc) {
|
|
return (
|
|
<img
|
|
src={displayEncounterImageSrc}
|
|
alt={encounter.npcName}
|
|
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
|
|
style={{
|
|
...DEFAULT_IMAGE_STYLE,
|
|
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
|
|
transformOrigin: 'bottom center',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const runtimeCustomWorldCharacter =
|
|
encounter.characterId ? getCharacterById(encounter.characterId) : null;
|
|
if (runtimeCustomWorldCharacter?.visual) {
|
|
return (
|
|
<MedievalNpcAnimator
|
|
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(runtimeCustomWorldCharacter.visual)}
|
|
className={`origin-bottom ${className ?? ''}`.trim()}
|
|
scale={1.36}
|
|
facing={facing}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (runtimeCustomWorldCharacter) {
|
|
return (
|
|
<div
|
|
className="h-full w-full"
|
|
style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}
|
|
>
|
|
<CharacterAnimator
|
|
state={state}
|
|
character={runtimeCustomWorldCharacter}
|
|
className={ROLE_CHARACTER_SPRITE_CLASS}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MedievalNpcAnimator
|
|
visualSpec={buildMedievalNpcVisual({
|
|
id: encounter.id ?? encounter.npcName,
|
|
npcName: encounter.npcName,
|
|
npcDescription: encounter.npcDescription,
|
|
npcAvatar: encounter.npcAvatar,
|
|
context: encounter.context,
|
|
} as Encounter)}
|
|
className={`origin-bottom ${className ?? ''}`.trim()}
|
|
scale={1.36}
|
|
facing={facing}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|