init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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;
sceneCombatants: 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,
sceneCombatants,
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,
sceneCombatants,
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,
sceneCombatants,
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}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,188 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
import {
CHARACTER_COMBAT_HP_TOP_PX,
ENTITY_CONTAINER_REM,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMirroredStageEntityLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
MONSTER_COMBAT_HP_TOP_PX,
} from './GameCanvasShared';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-liu',
kind: 'npc',
npcName: '柳无声',
npcDescription: '桥口旧识',
npcAvatar: '/npc-liu.png',
context: '断桥',
...overrides,
};
}
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
return {
id: 'npc-liu',
name: '柳无声',
action: '对峙',
description: '桥口旧识',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
encounter: createEncounter(),
...overrides,
};
}
function renderEntityLayer(effectNpcId: string | null) {
return renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={
effectNpcId
? {
eventId: 'effect-1',
npcId: effectNpcId,
delta: 3,
}
: null
}
sceneCombatants={[createHostileNpc()]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
}
describe('GameCanvasEntityLayer', () => {
it('uses mirrored stage anchors for player and opponent containers', () => {
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
});
it('lowers large monster sprites to the shared scene ground line', () => {
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 62})).toBe(-78);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 46})).toBe(-68);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 37})).toBe(-52);
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
});
it('uses scene npc visual anchors instead of template character foot offsets', () => {
const sceneNpcEncounter = createEncounter({
characterId: 'hero',
monsterPresetId: 'monster-20',
imageSrc: '/generated-custom-world-npc/shark.png',
});
const character = createCharacter();
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
.toBe('calc(18% + 68px - 78px)');
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
.toBe(-10);
});
it('lowers scene npc custom visuals even without character ids', () => {
const sceneNpcEncounter = createEncounter({
visual: {
race: 'elf',
bodyColor: 'blue',
headIndex: 0,
hairColorIndex: 1,
hairStyleFrame: 2,
facialHairEnabled: false,
facialHairColorIndex: 0,
facialHairStyleFrame: 0,
},
});
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
});
it('keeps combat hp bars above character and monster silhouettes', () => {
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
});
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
expect(html).toContain('aria-label="好感度变化 +3"');
});
it('does not render affinity effect on a different npc', () => {
const html = renderEntityLayer('npc-other');
expect(html).not.toContain('npc-affinity-effect-npc-liu');
expect(html).not.toContain('好感度变化 +3');
});
});

View File

@@ -0,0 +1,468 @@
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 {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {ResolvedAssetImage} from '../ResolvedAssetImage';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
CHARACTER_COMBAT_HP_TOP_PX,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
getSceneEntityZIndex,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
ROLE_CHARACTER_FRAME_CLASS,
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
SceneEncounterNpcSprite,
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;
npcAffinityEffect?: {
eventId: string;
npcId: string;
delta: number;
} | null;
sceneCombatants: 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,
npcAffinityEffect = null,
sceneCombatants,
monsters,
getHostileNpcOuterLeft,
groundBottom,
stageLiftPx,
encounter,
sideAnchor,
cameraAnchorX,
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
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={`查看${companion.character.name}详情`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
<RoleCharacterSprite
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
/>
</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 left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
>
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
</div>
)}
<SceneEntityButton
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
className="relative block"
>
<div className="relative">
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{playerCharacter && (
<RoleCharacterSprite
state={effectivePlayerAnimationState}
character={playerCharacter}
facing={effectivePlayerFacing}
/>
)}
</div>
</div>
{shouldShowPlayerDialogueIcon && (
<div className="absolute -top-9 right-1">
<DialogueBubbleIcon
active={dialogueIndicator?.activeSpeaker === 'player'}
flip={effectivePlayerFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
</motion.div>
{sceneCombatants.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 npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const hostileNpcBottomOffsetPx =
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
const opponentBottom = npcCharacter
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getEncounterCharacterBottomOffsetPx(
stageLiftPx,
npcEncounter,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
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={`查看${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>
)}
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);
})}
{shouldRenderPeacefulEncounter &&
(() => {
if (!encounter) {
return null;
}
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';
const peacefulAnchorX = RESOLVED_ENTITY_X_METERS;
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 = !peacefulResolvedCharacter &&
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulHostileBottomOffsetPx =
peacefulMonsterConfig
? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(encounter);
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
: stageLiftPx + peacefulHostileBottomOffsetPx;
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
return (
<div
className="absolute"
style={{
left: getMonsterWorldLeft(
sideAnchor,
peacefulAnchorX,
cameraAnchorX,
monsterAnchorMeters,
),
bottom: encounter.characterId
? getEncounterCharacterOpponentBottom(
groundBottom,
stageLiftPx,
encounter,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}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' ? `查看${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)]">
<ResolvedAssetImage
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
alt={encounter.npcName}
className="h-12 w-12 object-contain"
style={{imageRendering: 'pixelated'}}
/>
</div>
) : peacefulResolvedCharacter &&
!encounter.visual &&
!encounter.imageSrc?.trim() ? (
<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"
/>
) : (
<SceneEncounterNpcSprite
encounter={encounter}
state={AnimationState.IDLE}
facing={peacefulNpcSpriteFacing}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
/>
)}
</div>
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon
active={dialogueIndicator.activeSpeaker === 'npc'}
flip={peacefulNpcSpriteFacing === 'left'}
/>
</div>
)}
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);
})()}
</>
);
}

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

View File

@@ -0,0 +1,231 @@
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
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,
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
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,
customWorldProfile = null,
storyEngineMemory = null,
sceneHostileNpcs,
playerX,
playerOffsetY,
playerFacing,
playerActionMode = 'idle',
inBattle,
playerHp,
playerMaxHp,
activeCombatEffects = [],
companions = [],
dialogueIndicator = null,
npcAffinityEffect = 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
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
: null;
const activeSceneActBackground =
currentScenePreset?.id
? resolveActiveSceneActBackgroundImage({
profile: customWorldProfile,
sceneId: currentScenePreset.id,
storyEngineMemory,
})
: null;
const backgroundSrc = activeSceneActBackground
|| currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
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 closestHostileNpcDistance = sceneHostileNpcs.length > 0
? Math.min(...sceneHostileNpcs.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 playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
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
: scrollWorld
? playerWorldLeft
: playerStageLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
if (!scrollWorld && hostileNpc.animation !== 'attack') {
return opponentStageLeft;
}
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
};
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 ? sceneHostileNpcs.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}
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}
npcAffinityEffect={npcAffinityEffect}
sceneCombatants={sceneHostileNpcs}
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}
sceneCombatants={sceneHostileNpcs}
playerCharacter={playerCharacter}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}
playerOffsetY={playerOffsetY}
stageRef={stageRef}
/>
<GameCanvasOverlayLayer escapeLead={escapeLead} />
</div>
);
}

View File

@@ -0,0 +1,117 @@
import {AnimatePresence, motion} from 'motion/react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
import { SCENE_TITLE_GEAR_FILTER } from './GameCanvasShared';
interface GameCanvasSceneLayerProps {
backgroundLoadFailed: boolean;
backgroundSrc: string;
currentScenePreset: ScenePresetInfo | null;
resolvedWorldType: WorldType | null;
sceneTitleSpinToken: number;
onSceneNameClick?: (() => void) | null;
onBackgroundLoadError: () => void;
}
export function GameCanvasSceneLayer({
backgroundLoadFailed,
backgroundSrc,
currentScenePreset,
resolvedWorldType,
sceneTitleSpinToken,
onSceneNameClick = null,
onBackgroundLoadError,
}: GameCanvasSceneLayerProps) {
const {
resolvedUrl: resolvedBackgroundSrc,
shouldResolve: shouldResolveBackground,
} = useResolvedAssetReadUrl(backgroundSrc);
// 签名地址未返回前先显示渐变底色,避免浏览器直接访问私有原图触发 403。
const displayBackgroundSrc =
resolvedBackgroundSrc || (!shouldResolveBackground ? backgroundSrc : '');
return (
<>
{!backgroundLoadFailed && displayBackgroundSrc ? (
<img
src={displayBackgroundSrc}
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%)]" />
{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>
)}
</>
);
}

View File

@@ -0,0 +1,499 @@
import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
} from '../../data/medievalNpcVisuals';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
AnimationState,
Character,
CombatActionMode,
CombatVisualEffect,
CompanionRenderState,
CustomWorldProfile,
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryEngineMemoryState,
StoryNpcAffinityEffect,
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 ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
export const GENERIC_NPC_SCENE_SCALE = 1.72;
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 78;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const CHARACTER_COMBAT_HP_TOP_PX = -48;
export const MONSTER_COMBAT_HP_TOP_PX = -44;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = -48;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export type HostileNpcSceneAnchorConfig = {
frameHeight: number;
};
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 getMirroredStageEntityLeft(
sideAnchor: string,
side: 'player' | 'opponent',
) {
return side === 'player'
? sideAnchor
: `calc(100% - ${sideAnchor} - ${ENTITY_CONTAINER_REM}rem)`;
}
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 hasEncounterCustomSceneVisual(encounter: Encounter | null | undefined) {
return Boolean(
encounter?.visual
|| encounter?.imageSrc?.trim(),
);
}
export function getEncounterCharacterGroundOffset(
encounter: Encounter | null | undefined,
character: Character | null | undefined,
) {
if (hasEncounterCustomSceneVisual(encounter)) {
// 场景 NPC 的 AI 形象通常是方图或组合视觉,不能沿用模板角色脚底偏移。
return SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX;
}
return character?.groundOffsetY ?? 22;
}
export function getEncounterCharacterOpponentBottom(
groundBottom: string,
stageLiftPx: number,
encounter: Encounter | null | undefined,
character: Character | null | undefined,
) {
return `calc(${groundBottom} + ${stageLiftPx}px - ${getEncounterCharacterGroundOffset(encounter, character)}px)`;
}
export function getEncounterCharacterBottomOffsetPx(
stageLiftPx: number,
encounter: Encounter | null | undefined,
character: Character | null | undefined,
extraOffsetPx = 0,
) {
return stageLiftPx - getEncounterCharacterGroundOffset(encounter, character) + extraOffsetPx;
}
export function getSceneNpcVisualBottomOffsetPx(encounter: Encounter | null | undefined) {
return hasEncounterCustomSceneVisual(encounter)
? -SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX
: 0;
}
export function getHostileNpcSceneBottomOffsetPx(
monster: HostileNpcSceneAnchorConfig | null | undefined,
) {
if (!monster) return 0;
// 怪物动画帧和角色立绘不是同一套脚底锚点,大帧需要更明显地下沉到场景地面线。
if (monster.frameHeight >= 58) return -78;
if (monster.frameHeight >= 42) return -68;
if (monster.frameHeight >= 34) return -52;
return -28;
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return MONSTER_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_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 getEncounterCharacterOpponentBottom(
groundBottom,
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
targetHostileNpc.encounter,
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) {
const transform = `${facing === 'left' ? 'scaleX(-1) ' : ''}scale(${ROLE_CHARACTER_SCENE_IMAGE_SCALE})`;
return (
<img
src={displayEncounterImageSrc}
alt={encounter.npcName}
className={`h-full w-full origin-bottom object-contain ${className ?? ''}`.trim()}
style={{
...DEFAULT_IMAGE_STYLE,
transform,
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>
);
}

View File

@@ -0,0 +1,59 @@
import { Heart } from 'lucide-react';
import { motion } from 'motion/react';
import type { StoryNpcAffinityEffect } from '../../types';
interface NpcAffinityEffectBadgeProps {
effect: StoryNpcAffinityEffect;
}
/**
* 聊天结算后的好感度浮出特效。
* 仅负责表现层,不承担任何数值计算。
*/
export function NpcAffinityEffectBadge({
effect,
}: NpcAffinityEffectBadgeProps) {
const isPositive = effect.delta > 0;
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
return (
<motion.div
key={effect.eventId}
initial={{ opacity: 0, y: 24, scale: 0.8 }}
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
transition={{ duration: 1.45, ease: 'easeOut' }}
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
data-testid={`npc-affinity-effect-${effect.npcId}`}
aria-label={`好感度变化 ${deltaText}`}
>
{isPositive ? (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
<div className="relative flex items-center gap-1 text-rose-50">
<Heart className="h-3.5 w-3.5 fill-current" />
<span className="text-xs font-semibold tracking-[0.08em]">
{deltaText}
</span>
</div>
</>
) : (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
{deltaText}
</div>
</>
)}
<div
className={`absolute inset-0 rounded-full border ${
isPositive
? 'border-rose-200/45 bg-rose-500/18'
: 'border-slate-200/35 bg-slate-700/30'
}`}
/>
</motion.div>
);
}