431
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal file
431
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal 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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user