diff --git a/backend/cmd/server/server.exe b/backend/cmd/server/server.exe
index e6ad8b5..fb75048 100644
Binary files a/backend/cmd/server/server.exe and b/backend/cmd/server/server.exe differ
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index bcf4ccc..3bb0012 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -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))
}
}
diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go
index 8348d5b..9467a62 100644
--- a/backend/internal/game/offline.go
+++ b/backend/internal/game/offline.go
@@ -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 {
diff --git a/frontend/index.html b/frontend/index.html
index 0eb15f9..05b2a18 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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);
+ }
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 866f14f..79fb2aa 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(null);
const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
const [logEntries, setLogEntries] = useState([]);
+ /** Live combat narration (mirrors prefixed adventure log lines). */
+ const [combatLogLines, setCombatLogLines] = useState([]);
const [offlineReport, setOfflineReport] = useState(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) => {
- logIdCounter.current += 1;
- const entry: AdventureLogEntry = {
- id: logIdCounter.current,
- message,
- timestamp: Date.now(),
- };
- setLogEntries((prev) => [...prev, entry]);
+ const appendLogLine = useCallback((rawMessage: string) => {
+ setLogEntries((prev) =>
+ appendAdventureLogMessage(prev, rawMessage, () => {
+ logIdCounter.current += 1;
+ 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 */}
+
+
{/* Name Entry Screen */}
{needsName && }
diff --git a/frontend/src/game/adventureLogMap.ts b/frontend/src/game/adventureLogMap.ts
index a8b7157..026a168 100644
--- a/frontend/src/game/adventureLogMap.ts
+++ b/frontend/src/game/adventureLogMap.ts
@@ -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 };
}
diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts
index 2bf167c..317521c 100644
--- a/frontend/src/game/engine.ts
+++ b/frontend/src/game/engine.ts
@@ -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();
}
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index c7078e7..f505a02 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -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 {
diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts
index d595c84..a9b5342 100644
--- a/frontend/src/game/ws-handler.ts
+++ b/frontend/src/game/ws-handler.ts
@@ -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;
- engine.applyAdventureLogLine(p.message);
+ if (!shouldSuppressThoughtBubble(p.message)) {
+ engine.applyAdventureLogLine(p.message);
+ }
callbacks.onAdventureLogLine?.(p);
});
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index 9d12afc..44c0576 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -166,6 +166,7 @@ export const en = {
// Adventure log
noEventsYet: 'No events yet...',
+ combatLogTitle: 'Combat',
// Misc
adventureLog: 'Adventure Log',
diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts
index bb351a2..7ea6e2c 100644
--- a/frontend/src/i18n/ru.ts
+++ b/frontend/src/i18n/ru.ts
@@ -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',
diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts
index 1fecfcf..6f30c7f 100644
--- a/frontend/src/shared/constants.ts
+++ b/frontend/src/shared/constants.ts
@@ -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;
diff --git a/frontend/src/ui/AdventureLog.tsx b/frontend/src/ui/AdventureLog.tsx
index a7f01cf..0c73273 100644
--- a/frontend/src/ui/AdventureLog.tsx
+++ b/frontend/src/ui/AdventureLog.tsx
@@ -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 (
{entries.length === 0 && (
@@ -57,12 +82,39 @@ export function AdventureLogEntries({
{tr.noEventsYet}
)}
- {entries.map((entry) => (
-
- [{formatTime(entry.timestamp)}]
- {entry.message}
-
- ))}
+ {entries.map((entry, idx) => {
+ if (entry.kind === 'battle_group') {
+ const isLastBattle = idx === lastBattleIdx;
+ return (
+
+
+
+ [{formatTime(entry.timestamp)}]
+ {entry.title}
+
+
+
+ {entry.lines.map((line) => (
+
+ {line.message}
+
+ ))}
+
+
+ );
+ }
+ return (
+
+ [{formatTime(entry.timestamp)}]
+ {entry.message}
+
+ );
+ })}
);
}
diff --git a/frontend/src/ui/FloatingDamage.tsx b/frontend/src/ui/FloatingDamage.tsx
index da7fe6f..aec749a 100644
--- a/frontend/src/ui/FloatingDamage.tsx
+++ b/frontend/src/ui/FloatingDamage.tsx
@@ -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;