265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal file
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal file
126
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
307
src/components/game-canvas/GameCanvasShared.tsx
Normal file
307
src/components/game-canvas/GameCanvasShared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user