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;