fix combat ui

master
Denis Ranneft 1 month ago
parent 1d7bb9e101
commit 16287bb25b

Binary file not shown.

@ -986,6 +986,10 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
}) })
} }
if e.adventureLog != nil {
e.adventureLog(hero.ID, FormatEncounterLogLine(enemy.Name))
}
e.logger.Info("combat started", e.logger.Info("combat started",
"hero_id", hero.ID, "hero_id", hero.ID,
"enemy", enemy.Name, "enemy", enemy.Name,
@ -1370,7 +1374,7 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied." msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
} }
if msg != "" { if msg != "" {
e.adventureLog(cs.HeroID, msg) e.adventureLog(cs.HeroID, FormatBattleLogLine(msg))
} }
} }

@ -155,7 +155,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
} }
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) { encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
rewardDeps := s.rewardDeps(tickNow) rewardDeps := s.rewardDeps(tickNow)
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
if survived { if survived {

@ -17,6 +17,31 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: none; touch-action: none;
} }
.ah-adventure-details > summary {
list-style: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.ah-adventure-details > summary::-webkit-details-marker {
display: none;
}
.ah-adventure-summary-text {
flex: 1;
min-width: 0;
}
.ah-adventure-details > summary::after {
content: '▸';
flex-shrink: 0;
display: block;
opacity: 0.65;
transition: transform 0.12s ease;
line-height: 1.5;
}
.ah-adventure-details[open] > summary::after {
transform: rotate(90deg);
}
</style> </style>
</head> </head>
<body> <body>

@ -45,10 +45,15 @@ import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } f
import { Rarity } from './game/types'; import { Rarity } from './game/types';
import type { HeroState, BuffChargeState } from './game/types'; import type { HeroState, BuffChargeState } from './game/types';
import { useUiClock } from './hooks/useUiClock'; import { useUiClock } from './hooks/useUiClock';
import { adventureEntriesFromServerLog } from './game/adventureLogMap'; import {
adventureEntriesFromServerLog,
appendAdventureLogMessage,
} from './game/adventureLogMap';
import { parseAdventureLogLine } from './game/adventureLogMarkers';
import { HUD } from './ui/HUD'; import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen'; import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage'; import { FloatingDamage } from './ui/FloatingDamage';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast'; import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport'; import { OfflineReport } from './ui/OfflineReport';
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
@ -320,6 +325,8 @@ export function App() {
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; color: string } | null>(null); const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]); const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]);
/** Live combat narration (mirrors prefixed adventure log lines). */
const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null); const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
const [needsName, setNeedsName] = useState(false); const [needsName, setNeedsName] = useState(false);
const logIdCounter = useRef(0); const logIdCounter = useRef(0);
@ -349,14 +356,19 @@ export function App() {
const sheetNowMs = useUiClock(100); const sheetNowMs = useUiClock(100);
const addLogEntry = useCallback((message: string) => { const appendLogLine = useCallback((rawMessage: string) => {
setLogEntries((prev) =>
appendAdventureLogMessage(prev, rawMessage, () => {
logIdCounter.current += 1; logIdCounter.current += 1;
const entry: AdventureLogEntry = { return logIdCounter.current;
id: logIdCounter.current, }),
message, );
timestamp: Date.now(), const parsed = parseAdventureLogLine(rawMessage);
}; if (parsed.type === 'encounter') {
setLogEntries((prev) => [...prev, entry]); setCombatLogLines([parsed.title]);
} else if (parsed.type === 'battle') {
setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
}
}, []); }, []);
const refreshEquipment = useCallback(() => { const refreshEquipment = useCallback(() => {
@ -590,6 +602,10 @@ export function App() {
// Wire WS handler -- routes server messages to engine + UI callbacks // Wire WS handler -- routes server messages to engine + UI callbacks
wireWSHandler(ws, engine, { wireWSHandler(ws, engine, {
onCombatStart: () => {
setCombatLogLines([]);
},
onHeroStateReceived: (payload) => { onHeroStateReceived: (payload) => {
// Convert raw payload to HeroResponse shape and apply // Convert raw payload to HeroResponse shape and apply
const res = payload as unknown as HeroResponse; const res = payload as unknown as HeroResponse;
@ -598,6 +614,7 @@ export function App() {
}, },
onCombatEnd: (p) => { onCombatEnd: (p) => {
setCombatLogLines([]);
const loot = buildLootFromCombatEnd(p); const loot = buildLootFromCombatEnd(p);
engine.applyLoot(loot); engine.applyLoot(loot);
hapticNotification('success'); hapticNotification('success');
@ -636,6 +653,7 @@ export function App() {
}, },
onHeroRevived: () => { onHeroRevived: () => {
setCombatLogLines([]);
setToast({ message: tr.heroRevived, color: '#44cc44' }); setToast({ message: tr.heroRevived, color: '#44cc44' });
// "Hero revived" comes from server log + WS // "Hero revived" comes from server log + WS
}, },
@ -648,7 +666,7 @@ export function App() {
const town = townsRef.current.find((t) => t.id === p.townId) ?? null; const town = townsRef.current.find((t) => t.id === p.townId) ?? null;
setCurrentTown(town); setCurrentTown(town);
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`); appendLogLine(`Entered ${p.townName}`);
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null); setNpcVisitAwaitingProximity(null);
setSelectedNPC(null); setSelectedNPC(null);
@ -656,7 +674,7 @@ export function App() {
}, },
onAdventureLogLine: (p) => { onAdventureLogLine: (p) => {
addLogEntry(p.message); appendLogLine(p.message);
}, },
onTownNPCVisit: (p) => { onTownNPCVisit: (p) => {
@ -689,7 +707,7 @@ export function App() {
onNPCEncounterEnd: (p) => { onNPCEncounterEnd: (p) => {
if (p.reason === 'timeout') { if (p.reason === 'timeout') {
addLogEntry('Wandering merchant moved on'); appendLogLine('Wandering merchant moved on');
} }
setWanderingNPC(null); setWanderingNPC(null);
}, },
@ -1135,8 +1153,8 @@ export function App() {
sendNPCAlmsDecline(ws); sendNPCAlmsDecline(ws);
} }
setWanderingNPC(null); setWanderingNPC(null);
addLogEntry('Declined wandering merchant'); appendLogLine('Declined wandering merchant');
}, [addLogEntry]); }, [appendLogLine]);
// Show NPC interaction when near an NPC and not dismissed // Show NPC interaction when near an NPC and not dismissed
const showNPCInteraction = const showNPCInteraction =
@ -1191,6 +1209,14 @@ export function App() {
{/* Floating Damage Numbers */} {/* Floating Damage Numbers */}
<FloatingDamage damages={damages} /> <FloatingDamage damages={damages} />
<CombatLogPanel
visible={
gameState.phase === GamePhase.Fighting || gameState.phase === GamePhase.Dead
}
lines={combatLogLines}
anchor={gameState.enemyOnScreenRight !== false ? 'left' : 'right'}
/>
{/* Name Entry Screen */} {/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />} {needsName && <NameEntryScreen onNameSet={handleNameSet} />}

@ -1,5 +1,97 @@
import type { AdventureLogEntry } from './types'; import type { AdventureLogBattleGroup, AdventureLogEntry, AdventureLogPlainEntry } from './types';
import type { LogEntry } from '../network/api'; import type { LogEntry } from '../network/api';
import { parseAdventureLogLine } from './adventureLogMarkers';
/** Group server log rows (oldest first) into plain lines + battle groups. */
export function groupAdventureLogFromServer(
sortedOldestFirst: Array<{ id: number; message: string; timestamp: number }>,
): AdventureLogEntry[] {
const out: AdventureLogEntry[] = [];
let i = 0;
while (i < sortedOldestFirst.length) {
const row = sortedOldestFirst[i]!;
const parsed = parseAdventureLogLine(row.message);
if (parsed.type === 'encounter') {
const lines: { id: number; message: string }[] = [];
i++;
while (i < sortedOldestFirst.length) {
const inner = sortedOldestFirst[i]!;
const innerParsed = parseAdventureLogLine(inner.message);
if (innerParsed.type === 'battle') {
lines.push({ id: inner.id, message: innerParsed.text });
i++;
} else {
break;
}
}
const group: AdventureLogBattleGroup = {
kind: 'battle_group',
id: row.id,
title: parsed.title,
timestamp: row.timestamp,
lines,
};
out.push(group);
} else {
const text =
parsed.type === 'plain' ? parsed.text : parsed.type === 'battle' ? parsed.text : row.message;
const plain: AdventureLogPlainEntry = {
kind: 'line',
id: row.id,
message: text,
timestamp: row.timestamp,
};
out.push(plain);
i++;
}
}
return out;
}
export function appendAdventureLogMessage(
prev: AdventureLogEntry[],
rawMessage: string,
nextId: () => number,
): AdventureLogEntry[] {
const parsed = parseAdventureLogLine(rawMessage);
if (parsed.type === 'encounter') {
const group: AdventureLogBattleGroup = {
kind: 'battle_group',
id: nextId(),
title: parsed.title,
timestamp: Date.now(),
lines: [],
};
return [...prev, group];
}
if (parsed.type === 'battle') {
const last = prev[prev.length - 1];
if (last?.kind === 'battle_group') {
const lineId = nextId();
return [
...prev.slice(0, -1),
{
...last,
lines: [...last.lines, { id: lineId, message: parsed.text }],
},
];
}
const line: AdventureLogPlainEntry = {
kind: 'line',
id: nextId(),
message: parsed.text,
timestamp: Date.now(),
};
return [...prev, line];
}
const line: AdventureLogPlainEntry = {
kind: 'line',
id: nextId(),
message: parsed.text,
timestamp: Date.now(),
};
return [...prev, line];
}
/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */ /** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */
export function adventureEntriesFromServerLog(serverLog: LogEntry[]): { export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
@ -9,11 +101,12 @@ export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
const sorted = [...serverLog].sort( const sorted = [...serverLog].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
); );
const entries: AdventureLogEntry[] = sorted.map((entry) => ({ const flat = sorted.map((entry) => ({
id: Number(entry.id), id: Number(entry.id),
message: entry.message, message: entry.message,
timestamp: new Date(entry.createdAt).getTime(), timestamp: new Date(entry.createdAt).getTime(),
})); }));
const maxId = entries.reduce((m, e) => Math.max(m, e.id), 0); const entries = groupAdventureLogFromServer(flat);
const maxId = flat.reduce((m, e) => Math.max(m, e.id), 0);
return { entries, maxId }; return { entries, maxId };
} }

@ -78,6 +78,7 @@ export class GameEngine {
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null, routeWaypoints: null,
enemyOnScreenRight: undefined,
}; };
// ---- Server-driven position interpolation ---- // ---- Server-driven position interpolation ----
@ -202,6 +203,7 @@ export class GameEngine {
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null, routeWaypoints: null,
enemyOnScreenRight: undefined,
}; };
// Initialize display position // Initialize display position
@ -401,11 +403,14 @@ export class GameEngine {
y: this._heroDisplayY - 0.5, y: this._heroDisplayY - 0.5,
}; };
const enemyOnScreenRight = enemy.position.x >= this._heroDisplayX;
this._gameState = { this._gameState = {
...this._gameState, ...this._gameState,
phase: GamePhase.Fighting, phase: GamePhase.Fighting,
enemy, enemy,
loot: null, loot: null,
enemyOnScreenRight,
}; };
this._lootTimerMs = 0; this._lootTimerMs = 0;
this._thoughtText = null; this._thoughtText = null;
@ -492,6 +497,7 @@ export class GameEngine {
...this._gameState, ...this._gameState,
phase: GamePhase.Walking, phase: GamePhase.Walking,
enemy: null, enemy: null,
enemyOnScreenRight: undefined,
}; };
this._notifyStateChange(); this._notifyStateChange();
} }
@ -531,6 +537,7 @@ export class GameEngine {
...this._gameState, ...this._gameState,
phase: GamePhase.Walking, phase: GamePhase.Walking,
enemy: null, enemy: null,
enemyOnScreenRight: undefined,
}; };
this._notifyStateChange(); this._notifyStateChange();
} }

@ -199,6 +199,11 @@ export interface GameState {
serverTimeMs: number; serverTimeMs: number;
/** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */ /** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */
routeWaypoints: Array<{ x: number; y: number }> | null; routeWaypoints: Array<{ x: number; y: number }> | null;
/**
* During combat: whether the enemy is to the right of the hero in world space.
* UI docks the combat log panel on the opposite side from floating damage numbers.
*/
enemyOnScreenRight?: boolean;
} }
// ---- Rendering State (interpolated) ---- // ---- Rendering State (interpolated) ----
@ -223,12 +228,24 @@ export enum AnimationState {
// ---- Adventure Log ---- // ---- Adventure Log ----
export interface AdventureLogEntry { export interface AdventureLogPlainEntry {
kind: 'line';
id: number; id: number;
message: string; message: string;
timestamp: number; timestamp: number;
} }
/** Expandable block: encounter title + combat detail lines (server prefixes __AH_ENC__ / __AH_BAT__). */
export interface AdventureLogBattleGroup {
kind: 'battle_group';
id: number;
title: string;
timestamp: number;
lines: { id: number; message: string }[];
}
export type AdventureLogEntry = AdventureLogPlainEntry | AdventureLogBattleGroup;
// ---- Town & NPC & Quest ---- // ---- Town & NPC & Quest ----
export interface Town { export interface Town {

@ -30,10 +30,13 @@ import type {
} from './types'; } from './types';
import { DebuffType, EnemyType, Rarity } from './types'; import { DebuffType, EnemyType, Rarity } from './types';
import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants'; import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants';
import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
export interface WSHandlerCallbacks { export interface WSHandlerCallbacks {
/** Fires after combat_start is applied (clear transient combat UI). */
onCombatStart?: () => void;
onCombatEnd?: (payload: CombatEndPayload) => void; onCombatEnd?: (payload: CombatEndPayload) => void;
onHeroDied?: (payload: HeroDiedPayload) => void; onHeroDied?: (payload: HeroDiedPayload) => void;
onHeroRevived?: (payload: HeroRevivedPayload) => void; onHeroRevived?: (payload: HeroRevivedPayload) => void;
@ -110,6 +113,7 @@ export function wireWSHandler(
enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf, enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf,
}; };
engine.applyCombatStart(enemy); engine.applyCombatStart(enemy);
callbacks.onCombatStart?.();
}); });
ws.on('attack', (msg: ServerMessage) => { ws.on('attack', (msg: ServerMessage) => {
@ -185,7 +189,9 @@ export function wireWSHandler(
ws.on('adventure_log_line', (msg: ServerMessage) => { ws.on('adventure_log_line', (msg: ServerMessage) => {
const p = msg.payload as AdventureLogLinePayload; const p = msg.payload as AdventureLogLinePayload;
if (!shouldSuppressThoughtBubble(p.message)) {
engine.applyAdventureLogLine(p.message); engine.applyAdventureLogLine(p.message);
}
callbacks.onAdventureLogLine?.(p); callbacks.onAdventureLogLine?.(p);
}); });

@ -166,6 +166,7 @@ export const en = {
// Adventure log // Adventure log
noEventsYet: 'No events yet...', noEventsYet: 'No events yet...',
combatLogTitle: 'Combat',
// Misc // Misc
adventureLog: 'Adventure Log', adventureLog: 'Adventure Log',

@ -168,6 +168,7 @@ export const ru: Translations = {
// Adventure log // Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...', noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
combatLogTitle: '\u0411\u043e\u0439',
// Misc // Misc
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439', adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',

@ -46,9 +46,15 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
/** Max accumulated time before we drop frames (prevents spiral of death) */ /** Max accumulated time before we drop frames (prevents spiral of death) */
export const MAX_ACCUMULATED_MS = 250; export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds */ /** Floating damage number duration in milliseconds (normal hits, regen) */
export const DAMAGE_NUMBER_DURATION_MS = 2600; export const DAMAGE_NUMBER_DURATION_MS = 2600;
/** Longer float for crit / blocked / evaded so combat feedback stays readable */
export const DAMAGE_NUMBER_FEEDBACK_DURATION_MS = 4800;
/** Longer float for crit so combat feedback stays readable */
export const DAMAGE_NUMBER_CRIT_DURATION_MS = 2600;
/** Floating damage rise distance in pixels (vertical flight from anchor) */ /** Floating damage rise distance in pixels (vertical flight from anchor) */
export const DAMAGE_NUMBER_RISE_PX = 96; export const DAMAGE_NUMBER_RISE_PX = 96;

@ -32,6 +32,24 @@ const timestampStyle: CSSProperties = {
fontSize: 11, fontSize: 11,
}; };
const battleChildStyle: CSSProperties = {
paddingLeft: 14,
fontSize: 11,
color: '#9aa',
whiteSpace: 'normal',
overflow: 'visible',
textOverflow: 'unset',
lineHeight: 1.45,
};
const detailsStyle: CSSProperties = {
marginBottom: 4,
};
const summaryStyle: CSSProperties = {
cursor: 'pointer',
};
/** Scrollable adventure log list (Hero sheet Journal tab). */ /** Scrollable adventure log list (Hero sheet Journal tab). */
export function AdventureLogEntries({ export function AdventureLogEntries({
entries, entries,
@ -50,6 +68,13 @@ export function AdventureLogEntries({
} }
}, [entries.length, ref]); }, [entries.length, ref]);
const lastBattleIdx = (() => {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i]?.kind === 'battle_group') return i;
}
return -1;
})();
return ( return (
<div ref={ref} style={scrollAreaStyle}> <div ref={ref} style={scrollAreaStyle}>
{entries.length === 0 && ( {entries.length === 0 && (
@ -57,12 +82,39 @@ export function AdventureLogEntries({
{tr.noEventsYet} {tr.noEventsYet}
</div> </div>
)} )}
{entries.map((entry) => ( {entries.map((entry, idx) => {
if (entry.kind === 'battle_group') {
const isLastBattle = idx === lastBattleIdx;
return (
<details
key={entry.id}
className="ah-adventure-details"
style={detailsStyle}
open={isLastBattle}
>
<summary style={summaryStyle}>
<span className="ah-adventure-summary-text">
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
<span style={{ ...entryStyle, whiteSpace: 'normal' }}>{entry.title}</span>
</span>
</summary>
<div style={{ marginTop: 4 }}>
{entry.lines.map((line) => (
<div key={line.id} style={battleChildStyle}>
{line.message}
</div>
))}
</div>
</details>
);
}
return (
<div key={entry.id} style={entryStyle}> <div key={entry.id} style={entryStyle}>
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span> <span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
{entry.message} {entry.message}
</div> </div>
))} );
})}
</div> </div>
); );
} }

@ -2,6 +2,8 @@ import { useCallback, useEffect, useState, type CSSProperties } from 'react';
import { import {
DAMAGE_NUMBER_DURATION_MS, DAMAGE_NUMBER_DURATION_MS,
DAMAGE_NUMBER_DRIFT_PX, DAMAGE_NUMBER_DRIFT_PX,
DAMAGE_NUMBER_FEEDBACK_DURATION_MS,
DAMAGE_NUMBER_CRIT_DURATION_MS,
DAMAGE_NUMBER_RISE_PX, DAMAGE_NUMBER_RISE_PX,
} from '../shared/constants'; } from '../shared/constants';
import type { FloatingDamageData } from '../game/types'; import type { FloatingDamageData } from '../game/types';
@ -15,8 +17,19 @@ interface DamageNumberProps {
onExpire: (id: number) => void; onExpire: (id: number) => void;
} }
function feedbackDurationMs(data: FloatingDamageData): number {
if (data.kind === 'blocked' || data.kind === 'evaded') {
return DAMAGE_NUMBER_FEEDBACK_DURATION_MS;
}
if (data.kind === 'damage' && data.isCrit) {
return DAMAGE_NUMBER_CRIT_DURATION_MS;
}
return DAMAGE_NUMBER_DURATION_MS;
}
function DamageNumber({ data, onExpire }: DamageNumberProps) { function DamageNumber({ data, onExpire }: DamageNumberProps) {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const durationMs = feedbackDurationMs(data);
useEffect(() => { useEffect(() => {
let rafId: number; let rafId: number;
@ -24,7 +37,7 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
const animate = () => { const animate = () => {
const elapsed = performance.now() - start; const elapsed = performance.now() - start;
const p = Math.min(1, elapsed / DAMAGE_NUMBER_DURATION_MS); const p = Math.min(1, elapsed / durationMs);
setProgress(p); setProgress(p);
if (p < 1) { if (p < 1) {
@ -36,7 +49,7 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [data.createdAt, data.id, onExpire]); }, [data.createdAt, data.id, data.kind, data.isCrit, durationMs, onExpire]);
const driftDir = data.target === 'enemy' ? 1 : -1; const driftDir = data.target === 'enemy' ? 1 : -1;
const offsetX = progress * DAMAGE_NUMBER_DRIFT_PX * driftDir; const offsetX = progress * DAMAGE_NUMBER_DRIFT_PX * driftDir;

Loading…
Cancel
Save