Files
Genarrative/src/components/game-canvas/GameCanvasShared.tsx

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>
);
}