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