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",
"hero_id", hero.ID,
"enemy", enemy.Name,
@ -1370,7 +1374,7 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
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) {
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)
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
if survived {

@ -17,6 +17,31 @@
-webkit-tap-highlight-color: transparent;
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>
</head>
<body>

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

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

@ -199,6 +199,11 @@ export interface GameState {
serverTimeMs: number;
/** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */
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) ----
@ -223,12 +228,24 @@ export enum AnimationState {
// ---- Adventure Log ----
export interface AdventureLogEntry {
export interface AdventureLogPlainEntry {
kind: 'line';
id: number;
message: string;
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 ----
export interface Town {

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

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

@ -168,6 +168,7 @@ export const ru: Translations = {
// Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
combatLogTitle: '\u0411\u043e\u0439',
// Misc
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) */
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;
/** 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) */
export const DAMAGE_NUMBER_RISE_PX = 96;

@ -32,6 +32,24 @@ const timestampStyle: CSSProperties = {
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). */
export function AdventureLogEntries({
entries,
@ -50,6 +68,13 @@ export function AdventureLogEntries({
}
}, [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 (
<div ref={ref} style={scrollAreaStyle}>
{entries.length === 0 && (
@ -57,12 +82,39 @@ export function AdventureLogEntries({
{tr.noEventsYet}
</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}>
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
{entry.message}
</div>
))}
);
})}
</div>
);
}

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

Loading…
Cancel
Save