import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { GameEngine } from './game/engine'; import { GamePhase, BuffType, type GameState, type FloatingDamageData, type ActiveBuff, type NPCData } from './game/types'; import type { NPCEncounterEvent } from './game/types'; import { GameWebSocket } from './network/websocket'; import { wireWSHandler, sendActivateBuff, sendUsePotion, sendRevive, sendNPCAlmsAccept, sendNPCAlmsDecline, buildLootFromCombatEnd, buildMerchantLootDrop, } from './game/ws-handler'; import { ApiError, initHero, ackChangelog, getAdventureLog, getTowns, getTownNPCs, getTownBuildings, getHeroQuests, getHeroEquipment, claimQuest, abandonQuest, getAchievements, getNearbyHeroes, buyPotion, healAtNPC, requestRevive, defaultNpcShopCosts, npcShopCostsFromInit, } from './network/api'; import type { HeroResponse, Achievement, ChangelogPayload } from './network/api'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types'; import type { OfflineReport as OfflineReportData } from './network/api'; import { BUFF_COOLDOWN_MS, BUFF_DURATION_MS, debuffDurationsFromCatalog, mapHeroBuffsFromServer, mapHeroDebuffsFromServer, } from './network/buffMap'; import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram'; import { Rarity } from './game/types'; import type { HeroState, BuffChargeState } from './game/types'; import { useUiClock } from './hooks/useUiClock'; 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'; import { NPCDialog } from './ui/NPCDialog'; import { NameEntryScreen } from './ui/NameEntryScreen'; import { ChangelogModal } from './ui/ChangelogModal'; import { AchievementsPanel } from './ui/AchievementsPanel'; import { Minimap } from './ui/Minimap'; import { NPCInteraction } from './ui/NPCInteraction'; import { WanderingNPCPopup } from './ui/WanderingNPCPopup'; import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n'; const appStyle: CSSProperties = { width: '100%', height: '100%', position: 'relative', overflow: 'hidden', }; const canvasContainerStyle: CSSProperties = { width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, }; const connectionBanner: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, textAlign: 'center', padding: '4px 0', fontSize: 12, fontWeight: 600, color: '#fff', backgroundColor: 'rgba(204, 51, 51, 0.85)', zIndex: 200, }; function buffCdStorageKey(heroId: number): string { return `autohero-buffCd-v1-${heroId}`; } function pruneCooldownEnds( raw: Partial>, now: number, ): Partial> { const out: Partial> = {}; for (const [k, v] of Object.entries(raw)) { if (typeof v === 'number' && v > now && (Object.values(BuffType) as string[]).includes(k)) { out[k as BuffType] = v; } } return out; } function loadCooldownsFromStorage(heroId: number): Partial> { try { const raw = sessionStorage.getItem(buffCdStorageKey(heroId)); if (!raw) return {}; const parsed = JSON.parse(raw) as Partial>; return pruneCooldownEnds(parsed, Date.now()); } catch { return {}; } } function saveCooldownsToStorage(heroId: number, ends: Partial>): void { try { const pruned = pruneCooldownEnds(ends, Date.now()); sessionStorage.setItem(buffCdStorageKey(heroId), JSON.stringify(pruned)); } catch { /* ignore quota / private mode */ } } function xpToNextLevel(level: number): number { if (level < 1) level = 1; if (level <= 9) return Math.round(180 * Math.pow(1.28, level - 1)); if (level <= 29) return Math.round(1450 * Math.pow(1.15, level - 10)); return Math.round(23000 * Math.pow(1.10, level - 30)); } /** Map backend buffCharges (keyed by string) into typed Partial>. */ function mapBuffCharges( raw: Record | undefined, ): Partial> { if (!raw) return {}; const out: Partial> = {}; const validTypes = new Set(Object.values(BuffType) as string[]); for (const [key, val] of Object.entries(raw)) { if (validTypes.has(key)) { out[key as BuffType] = { remaining: val.remaining, periodEnd: val.periodEnd }; } } return out; } /** Map backend equipment record to typed EquipmentItem map. */ function mapEquipment( raw: HeroResponse['equipment'], res: HeroResponse, ): Record { const out: Record = {}; // REST uses `equipment`, WS hero_state from Go uses `gear`. Treat JSON null like missing. const merged = raw ?? res.gear; const rawSlots = merged != null && typeof merged === 'object' ? merged : undefined; if (rawSlots) { for (const [slot, item] of Object.entries(rawSlots)) { out[slot] = { id: item.id, slot: item.slot ?? slot, formId: item.formId ?? '', name: item.name, rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: item.ilvl ?? 1, primaryStat: item.primaryStat ?? 0, statType: item.statType ?? 'mixed', }; } } // Fallback: populate main_hand from legacy weapon if not already set if (!out['main_hand'] && res.weapon) { out['main_hand'] = { id: res.weapon.id ?? 0, slot: 'main_hand', formId: '', name: res.weapon.name ?? 'Unknown', rarity: (res.weapon.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: res.weapon.ilvl ?? 1, primaryStat: res.weapon.damage ?? 0, statType: 'attack', }; } if (!out['chest'] && res.armor) { out['chest'] = { id: res.armor.id ?? 0, slot: 'chest', formId: '', name: res.armor.name ?? 'Unknown', rarity: (res.armor.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: res.armor.ilvl ?? 1, primaryStat: res.armor.defense ?? 0, statType: 'defense', }; } return out; } /** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */ function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): TownData { const npcData: NPCData[] | undefined = npcs?.map((n) => ({ id: n.id, name: n.name, type: n.type, worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, buildingId: n.buildingId, })); return { id: town.id, name: town.name, centerX: town.worldX, centerY: town.worldY, radius: town.radius, biome: town.biome, levelMin: town.levelMin, size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS', npcs: npcData, buildings: buildings, }; } function heroResponseToState(res: HeroResponse): HeroState { const now = Date.now(); return { id: res.id, hp: res.hp, maxHp: res.maxHp, position: { x: res.positionX ?? 0, y: res.positionY ?? 0 }, serverActivityState: res.state, restKind: res.restKind, excursionPhase: res.excursionPhase, attackSpeed: res.attackSpeed ?? res.speed, damage: res.attackPower ?? res.attack, defense: res.defensePower ?? res.defense, weaponType: (res.weapon?.type ?? 'sword') as HeroState['weaponType'], weaponName: res.weapon?.name ?? '', weaponRarity: (res.weapon?.rarity ?? 'common') as Rarity, weaponIlvl: res.weapon?.ilvl, armorType: (res.armor?.type ?? 'medium') as HeroState['armorType'], armorName: res.armor?.name ?? '', armorRarity: (res.armor?.rarity ?? 'common') as Rarity, armorIlvl: res.armor?.ilvl, activeBuffs: mapHeroBuffsFromServer(res.buffs, now), debuffs: mapHeroDebuffsFromServer(res.debuffs, now), level: res.level, xp: res.xp, xpToNext: res.xpToNext ?? xpToNextLevel(res.level || 1), gold: res.gold, strength: res.strength, constitution: res.constitution, agility: res.agility, luck: res.luck, reviveCount: res.reviveCount, subscriptionActive: res.subscriptionActive, buffFreeChargesRemaining: res.buffFreeChargesRemaining, buffQuotaPeriodEnd: res.buffQuotaPeriodEnd, buffCharges: mapBuffCharges(res.buffCharges), potions: res.potions ?? 0, moveSpeed: res.moveSpeed, equipment: mapEquipment(res.equipment, res), inventory: mapInventoryFromResponse(res.inventory), debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog), }; } function mapInventoryFromResponse(raw: HeroResponse['inventory']): EquipmentItem[] | undefined { if (!raw?.length) return undefined; return raw.map((it) => ({ id: it.id, slot: it.slot, formId: it.formId ?? '', name: it.name, rarity: it.rarity as Rarity, ilvl: it.ilvl ?? 1, primaryStat: it.primaryStat ?? 0, statType: it.statType ?? 'mixed', })); } export function App() { const [locale, setLocale] = useState(() => detectLocale()); const translations = useMemo(() => getTranslations(locale), [locale]); const handleSetLocale = useCallback((l: Locale) => { setLocale(l); try { localStorage.setItem('autohero_locale', l); } catch { /* ignore */ } }, []); const tr = translations; const canvasRef = useRef(null); const engineRef = useRef(null); const wsRef = useRef(null); const [gameState, setGameState] = useState({ phase: GamePhase.Walking, hero: null, enemy: null, loot: null, lastVictoryLoot: null, tick: 0, serverTimeMs: 0, routeWaypoints: null, }); const [damages, setDamages] = useState([]); const [wsConnected, setWsConnected] = useState(false); const [wsEverConnected, setWsEverConnected] = useState(false); const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState< Partial> >({}); 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); type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string }; const pendingChangelogRef = useRef(null); const [changelogOpen, setChangelogOpen] = useState(null); const logIdCounter = useRef(0); const nearbyIntervalRef = useRef | null>(null); // Quest system state const [towns, setTowns] = useState([]); const townsRef = useRef([]); const [heroQuests, setHeroQuests] = useState([]); const [currentTown, setCurrentTown] = useState(null); const [selectedNPC, setSelectedNPC] = useState(null); const [heroSheetOpen, setHeroSheetOpen] = useState(false); const [heroSheetInitialTab, setHeroSheetInitialTab] = useState('stats'); // NPC interaction state (server-driven via town_enter) const [nearestNPC, setNearestNPC] = useState(null); const [npcInteractionDismissed, setNpcInteractionDismissed] = useState(null); /** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */ const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState(null); // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts); // Achievements const [achievements, setAchievements] = useState([]); const prevAchievementsRef = useRef([]); const sheetNowMs = useUiClock(100); 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(() => { const telegramId = getTelegramUserId() ?? 1; getHeroEquipment(telegramId) .then((eqMap) => { const merged: Record = {}; for (const [slot, item] of Object.entries(eqMap)) { merged[slot] = { id: item.id, slot: item.slot ?? slot, formId: item.formId ?? '', name: item.name, rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: item.ilvl ?? 1, primaryStat: item.primaryStat ?? 0, statType: item.statType ?? 'mixed', }; } const engine = engineRef.current; if (engine?.gameState.hero) { const prevEquip = engine.gameState.hero.equipment ?? {}; const combined = { ...prevEquip, ...merged }; engine.gameState.hero.equipment = combined; } setGameState((prev) => { if (!prev.hero) return prev; const prevEquip = prev.hero.equipment ?? {}; return { ...prev, hero: { ...prev.hero, equipment: { ...prevEquip, ...merged } } }; }); }) .catch(() => console.warn('[App] Could not fetch equipment')); }, []); const refreshHeroQuests = useCallback(() => { const telegramId = getTelegramUserId() ?? 1; getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not refresh hero quests')); }, []); // Initialize engine and WebSocket useEffect(() => { const container = canvasRef.current; if (!container) return; // ---- Game Engine ---- const engine = new GameEngine(); engineRef.current = engine; engine.onStateChange((state) => { setGameState(state); }); // Wire up damage events from the engine engine.onDamage((dmg: FloatingDamageData) => { setDamages((prev) => [...prev, dmg]); if (dmg.kind === 'damage') { if (dmg.isCrit) { hapticImpact('heavy'); } else { hapticImpact('light'); } engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150); } else if (dmg.kind === 'stunned') { hapticImpact('light'); engine.camera.shake(3, 120); } }); engine.init(container).then(async () => { let shouldOpenWS = false; try { const telegramId = getTelegramUserId() ?? 1; const initRes = await initHero(telegramId); setNpcShopCosts(npcShopCostsFromInit(initRes)); if (initRes.showChangelog && initRes.changelog) { const bundle: ChangelogOpen = { payload: initRes.changelog, serverVersion: initRes.serverVersion, }; if (initRes.needsName) { pendingChangelogRef.current = bundle; } else { setChangelogOpen(bundle); } } // Gate game start behind name entry — no hero row until POST /hero/name if (initRes.needsName) { setNeedsName(true); if (initRes.hero) { const heroState = heroResponseToState(initRes.hero); engine.initFromServer(heroState, initRes.hero.state); } getTowns() .then(async (t) => { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns (name gate)')); console.info('[App] Hero needs name, showing name entry screen'); return; } if (!initRes.hero) { console.error('[App] init: missing hero without needsName'); setConnectionError('Invalid server response.'); return; } shouldOpenWS = true; const heroState = heroResponseToState(initRes.hero); engine.initFromServer(heroState, initRes.hero.state); engine.setHeroName(initRes.hero.name); console.info('[App] Loaded hero from server, id=', initRes.hero.id); if (initRes.offlineReport && initRes.offlineReport.monstersKilled > 0) { const r = initRes.offlineReport; console.info(`[Offline] ${r.message} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels`); setOfflineReport(r); } // Fetch towns, then their NPCs, and pass everything to the engine getTowns() .then(async (t) => { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { console.warn('[App] Error fetching town NPCs/buildings'); } const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns')); getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not fetch hero quests')); refreshEquipment(); getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => console.warn('[App] Could not fetch achievements')); // Poll nearby heroes every 5 seconds const nearbyInterval = setInterval(() => { getNearbyHeroes(telegramId) .then((heroes) => engine.setNearbyHeroes(heroes.map((h) => ({ id: h.id, name: h.name, level: h.level, positionX: h.positionX, positionY: h.positionY, })))) .catch(() => {}); }, 5000); getNearbyHeroes(telegramId) .then((heroes) => engine.setNearbyHeroes(heroes.map((h) => ({ id: h.id, name: h.name, level: h.level, positionX: h.positionX, positionY: h.positionY, })))) .catch(() => {}); nearbyIntervalRef.current = nearbyInterval; // Fetch adventure log (same source as server DB; response shape { log: [...] }) try { const serverLog = await getAdventureLog(telegramId, 50); const { entries, maxId } = adventureEntriesFromServerLog(serverLog); logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); } catch { console.warn('[App] Could not fetch adventure log'); } } catch (err) { console.error('[App] Backend not available. Game requires a server connection:', err); setConnectionError('Cannot reach game server. Please try again later.'); return; } engine.start(); if (shouldOpenWS) { ws.connect(); } }).catch((err) => { console.error('[App] Failed to initialize game engine:', err); }); // ---- WebSocket ---- const ws = new GameWebSocket(); wsRef.current = ws; // Pass telegram ID so the WS connection identifies the correct hero. const wsTelegramId = getTelegramUserId() ?? 1; ws.setTelegramId(wsTelegramId); ws.onConnectionState((connState) => { const connected = connState === 'connected'; setWsConnected(connected); if (connected) setWsEverConnected(true); }); // 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; const heroState = heroResponseToState(res); engine.applyHeroState(heroState); }, onCombatEnd: (p) => { setCombatLogLines([]); const loot = buildLootFromCombatEnd(p); engine.applyLoot(loot); hapticNotification('success'); const parts: string[] = []; if (p.xpGained > 0) parts.push(`+${p.xpGained} XP`); if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`); const equipDrop = (p.loot ?? []).find( (l) => l.itemType !== 'gold' && l.itemType !== 'potion', ); if (equipDrop?.name) parts.push(`found ${equipDrop.name}`); // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line if (p.leveledUp && p.newLevel) { setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); } // Refresh quests, equipment, achievements after combat const tid = getTelegramUserId() ?? 1; getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {}); refreshEquipment(); getAchievements(tid).then((a) => { const prevIds = new Set(prevAchievementsRef.current.filter((x) => x.unlocked).map((x) => x.id)); const newlyUnlocked = a.filter((x) => x.unlocked && !prevIds.has(x.id)); for (const ach of newlyUnlocked) { setToast({ message: `Achievement unlocked: ${ach.title}!`, color: '#ffd700' }); hapticNotification('success'); } prevAchievementsRef.current = a; setAchievements(a); }).catch(() => {}); }, onHeroDied: (_p) => { hapticNotification('error'); // Death line comes from server log + WS }, onHeroRevived: () => { setCombatLogLines([]); setToast({ message: tr.heroRevived, color: '#44cc44' }); // "Hero revived" comes from server log + WS }, onBuffApplied: (_p) => { // Buff activation comes from server log + WS }, onTownEnter: (p) => { const town = townsRef.current.find((t) => t.id === p.townId) ?? null; setCurrentTown(town); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); appendLogLine(`Entered ${p.townName}`); setNearestNPC(null); setNpcVisitAwaitingProximity(null); setSelectedNPC(null); setNpcInteractionDismissed(null); }, onAdventureLogLine: (p) => { appendLogLine(p.message); }, onTownNPCVisit: (p) => { setNearestNPC(null); setNpcInteractionDismissed(null); setNpcVisitAwaitingProximity({ id: p.npcId, name: p.name, type: p.type as NPCData['type'], worldX: p.worldX ?? 0, worldY: p.worldY ?? 0, }); }, onTownExit: () => { setCurrentTown(null); setNearestNPC(null); setNpcVisitAwaitingProximity(null); }, onNPCEncounter: (p) => { const npcEvent: NPCEncounterEvent = { type: 'npc_event', npcName: p.npcName, message: `${p.npcName} approaches!`, cost: p.cost, }; setWanderingNPC(npcEvent); }, onNPCEncounterEnd: (p) => { if (p.reason === 'timeout') { appendLogLine('Wandering merchant moved on'); } setWanderingNPC(null); }, onLevelUp: (p) => { setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); // Level-up lines come from server log + WS }, onEquipmentChange: (p) => { setToast({ message: t(tr.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' }); // Equipment line comes from server log + WS refreshEquipment(); }, onPotionCollected: (p) => { setToast({ message: t(tr.potionsCollected, { count: p.count }), color: '#44cc44' }); }, onQuestProgress: (p) => { setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId ? { ...hq, progress: p.current } : hq, ), ); if (p.title) { setToast({ message: t(tr.questProgress, { title: p.title, current: p.current, target: p.target }), color: '#44aaff', }); } }, onQuestComplete: (p) => { setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId ? { ...hq, status: 'completed' as const } : hq, ), ); setToast({ message: t(tr.questCompleted, { title: p.title }), color: '#ffd700' }); hapticNotification('success'); }, onError: (p) => { setToast({ message: p.message, color: '#ff4444' }); }, onMerchantLoot: (p) => { const loot = buildMerchantLootDrop(p); const eng = engineRef.current; if (loot && eng) { eng.applyLoot(loot); } hapticNotification('success'); refreshEquipment(); }, }); // ---- Telegram Theme Listener ---- const unsubTheme = onThemeChanged(); // ---- Cleanup ---- return () => { engine.destroy(); ws.disconnect(); unsubTheme(); if (nearbyIntervalRef.current) { clearInterval(nearbyIntervalRef.current); nearbyIntervalRef.current = null; } engineRef.current = null; wsRef.current = null; }; }, []); // Open trader / quest / healer panel only after the hero sprite has reached the NPC (not on town_enter). useEffect(() => { if (!npcVisitAwaitingProximity) return; const pending = npcVisitAwaitingProximity; const proximityR = 0.55; const proximityR2 = proximityR * proximityR; const timeoutMs = 5000; const started = performance.now(); let raf = 0; const step = () => { const eng = engineRef.current; let closeEnough = false; if (eng) { const { x, y } = eng.getHeroDisplayWorldPosition(); const dx = x - pending.worldX; const dy = y - pending.worldY; closeEnough = dx * dx + dy * dy <= proximityR2; } if (closeEnough || performance.now() - started > timeoutMs) { const role = pending.type === 'merchant' ? tr.shopLabel : pending.type === 'healer' ? tr.healerLabel : tr.questLabel; setToast({ message: `${role}: ${pending.name}`, color: '#c9a227' }); setNearestNPC(pending); setNpcVisitAwaitingProximity(null); return; } raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [npcVisitAwaitingProximity, tr]); // Restore per-hero buff button cooldowns useEffect(() => { const id = gameState.hero?.id; if (id == null) return; const loaded = loadCooldownsFromStorage(id); if (Object.keys(loaded).length === 0) return; setBuffCooldownEndsAt((prev) => ({ ...loaded, ...prev })); }, [gameState.hero?.id]); // ---- Handlers ---- const handleBuffActivate = useCallback((type: BuffType) => { const engine = engineRef.current; const ws = wsRef.current; const hero = engine?.gameState.hero; const now = Date.now(); if (engine?.gameState.phase === GamePhase.Dead) { return; } // Check per-buff charge quota if (hero) { const charge = hero.buffCharges?.[type]; if (charge != null && charge.remaining <= 0) { const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); setToast({ message: t(tr.noChargesLeft, { label }), color: '#ff8844' }); return; } } // Optimistic update if (engine && hero) { const durationMs = BUFF_DURATION_MS[type]; const optimisticBuff: ActiveBuff = { type, remainingMs: durationMs, durationMs, cooldownMs: BUFF_COOLDOWN_MS[type], cooldownRemainingMs: 0, expiresAtMs: now + durationMs, }; const alreadyActive = hero.activeBuffs.some( (b) => b.type === type && (b.expiresAtMs ?? 0) > now, ); const updatedBuffs = alreadyActive ? hero.activeBuffs.map((b) => (b.type === type ? optimisticBuff : b)) : [...hero.activeBuffs, optimisticBuff]; let { damage, defense, attackSpeed, hp, maxHp } = hero; if (!alreadyActive) { switch (type) { case BuffType.Rage: damage = Math.round(damage * 2); break; case BuffType.PowerPotion: damage = Math.round(damage * 2.5); break; case BuffType.WarCry: attackSpeed = Math.round(attackSpeed * 2 * 100) / 100; break; case BuffType.Heal: hp = Math.min(maxHp, hp + Math.round(maxHp * 0.5)); break; } } engine.patchHeroCombat({ damage, defense, attackSpeed, activeBuffs: updatedBuffs, }); if (type === BuffType.Heal && hp !== hero.hp) { engine.patchHeroHp(hp); } setBuffCooldownEndsAt((prev) => { const next = { ...prev, [type]: now + BUFF_COOLDOWN_MS[type] }; if (hero.id != null) saveCooldownsToStorage(hero.id, next); return next; }); // Optimistic decrement of per-buff charge (server always consumes via ConsumeBuffCharge) const currentCharge = hero.buffCharges?.[type]; if (currentCharge != null && currentCharge.remaining > 0) { const updatedCharges: Partial> = { ...hero.buffCharges, [type]: { ...currentCharge, remaining: currentCharge.remaining - 1 }, }; engine.patchHeroBuffQuota({ buffFreeChargesRemaining: hero.buffFreeChargesRemaining, buffQuotaPeriodEnd: hero.buffQuotaPeriodEnd, buffCharges: updatedCharges, }); } hapticImpact('medium'); // Server logs buff activation + sends adventure_log_line } // Send command to server via WebSocket if (ws) { sendActivateBuff(ws, type); } }, []); const handleRevive = useCallback(() => { const ws = wsRef.current; if (ws && ws.getState() === 'connected') { sendRevive(ws); } else { // Fallback to REST if WS not connected const telegramId = getTelegramUserId() ?? 1; requestRevive(telegramId) .then((hero) => { const engine = engineRef.current; if (engine) { const state = heroResponseToState(hero); engine.applyHeroRevived(state.hp); engine.applyHeroState(state); } }) .catch((err) => { console.warn('[App] Revive failed:', err); }); } }, []); const handleQuestClaim = useCallback( (heroQuestId: number) => { const telegramId = getTelegramUserId() ?? 1; claimQuest(heroQuestId, telegramId) .then((hero) => { const merged = heroResponseToState(hero); const engine = engineRef.current; if (engine) { const pos = engine.gameState.hero?.position; if (pos) merged.position = pos; engine.applyHeroState(merged); } setToast({ message: tr.questRewardsClaimed, color: '#ffd700' }); hapticNotification('success'); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to claim quest:', err); setToast({ message: tr.failedToClaimRewards, color: '#ff4444' }); }); }, [refreshHeroQuests], ); const handleQuestAbandon = useCallback( (heroQuestId: number) => { const telegramId = getTelegramUserId() ?? 1; abandonQuest(heroQuestId, telegramId) .then(() => { setToast({ message: tr.questAbandoned, color: '#ff8844' }); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to abandon quest:', err); setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' }); }); }, [refreshHeroQuests], ); const handleNPCHeroUpdated = useCallback((hero: HeroResponse) => { const merged = heroResponseToState(hero); const engine = engineRef.current; if (engine) { const pos = engine.gameState.hero?.position; if (pos) merged.position = pos; engine.applyHeroState(merged); } }, []); const handleNameSet = useCallback((hero: HeroResponse) => { setNeedsName(false); const engine = engineRef.current; if (engine) { const heroState = heroResponseToState(hero); engine.initFromServer(heroState, hero.state); engine.setHeroName(hero.name); engine.start(); wsRef.current?.connect(); const telegramId = getTelegramUserId() ?? 1; getTowns() .then(async (t) => { setTowns(t); const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns')); getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not fetch hero quests')); getAdventureLog(telegramId, 50) .then((serverLog) => { const { entries, maxId } = adventureEntriesFromServerLog(serverLog); logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); }) .catch(() => console.warn('[App] Could not fetch adventure log')); getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => {}); if (pendingChangelogRef.current) { setChangelogOpen(pendingChangelogRef.current); pendingChangelogRef.current = null; } } }, []); const handleDismissChangelog = useCallback(() => { setChangelogOpen(null); const telegramId = getTelegramUserId() ?? 1; ackChangelog(telegramId).catch(() => console.warn('[App] changelog ack failed')); }, []); const handleUsePotion = useCallback(() => { const ws = wsRef.current; const hero = engineRef.current?.gameState.hero; if (!hero || (hero.potions ?? 0) <= 0) return; // Send via WS if (ws) { sendUsePotion(ws); } hero.potions-- hapticImpact('medium'); // Server logs potion use + sends adventure_log_line }, []); // ---- NPC Interaction Handlers ---- const handleNPCViewQuests = useCallback((npc: NPCData) => { const matchedNPC: NPC = { id: npc.id, townId: 0, name: npc.name, type: npc.type, offsetX: 0, offsetY: 0, }; setSelectedNPC(matchedNPC); setNpcInteractionDismissed(npc.id); }, []); const handleNPCBuyPotion = useCallback((_npc: NPCData) => { const telegramId = getTelegramUserId() ?? 1; buyPotion(telegramId) .then((hero) => { hapticImpact('medium'); setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' }); handleNPCHeroUpdated(hero); // Server logs purchase + WS }) .catch((err) => { console.warn('[App] Failed to buy potion:', err); if (err instanceof ApiError) { try { const j = JSON.parse(err.body) as { error?: string }; setToast({ message: j.error ?? tr.failedToBuyPotion, color: '#ff4444' }); } catch { setToast({ message: tr.failedToBuyPotion, color: '#ff4444' }); } } }); }, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]); const handleNPCHeal = useCallback((npc: NPCData) => { const telegramId = getTelegramUserId() ?? 1; healAtNPC(telegramId, npc.id) .then((hero) => { hapticImpact('medium'); setToast({ message: tr.healedToFull, color: '#44cc44' }); handleNPCHeroUpdated(hero); // Server logs heal + WS }) .catch((err) => { console.warn('[App] Failed to heal:', err); if (err instanceof ApiError) { try { const j = JSON.parse(err.body) as { error?: string }; setToast({ message: j.error ?? tr.failedToHeal, color: '#ff4444' }); } catch { setToast({ message: tr.failedToHeal, color: '#ff4444' }); } } }); }, [handleNPCHeroUpdated]); const handleNPCInteractionDismiss = useCallback(() => { if (nearestNPC) { setNpcInteractionDismissed(nearestNPC.id); } }, [nearestNPC]); // ---- Wandering NPC Encounter Handlers (via WS) ---- const handleWanderingAccept = useCallback(() => { const ws = wsRef.current; if (ws) { sendNPCAlmsAccept(ws); } setWanderingNPC(null); // Alms outcome is logged on server + WS when trade completes }, []); const handleWanderingDecline = useCallback(() => { const ws = wsRef.current; if (ws) { sendNPCAlmsDecline(ws); } setWanderingNPC(null); appendLogLine('Declined wandering merchant'); }, [appendLogLine]); // Show NPC interaction when near an NPC and not dismissed const showNPCInteraction = nearestNPC !== null && npcInteractionDismissed !== nearestNPC.id && (gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) && !selectedNPC; const completedQuestCount = useMemo( () => heroQuests.filter((q) => q.status === 'completed').length, [heroQuests], ); const dismissToast = useCallback(() => setToast(null), []); const questClaimDisabled = gameState.phase === GamePhase.Dead || (gameState.hero?.hp ?? 0) <= 0; return (
{/* PixiJS Canvas */}
{/* React UI Overlay */} { setHeroSheetInitialTab('stats'); setHeroSheetOpen(true); }} /> {gameState.hero && ( setHeroSheetOpen(false)} initialTab={heroSheetInitialTab} hero={gameState.hero} nowMs={sheetNowMs} equipment={gameState.hero.equipment ?? {}} logEntries={logEntries} quests={heroQuests} onQuestClaim={handleQuestClaim} onQuestAbandon={handleQuestAbandon} questClaimDisabled={questClaimDisabled} /> )} {/* Floating Damage Numbers */} {/* Name Entry Screen */} {needsName && } {changelogOpen && ( )} {/* Death Screen */} {/* Toast Notification */} {toast && ( )} {/* Town Name Indicator */} {currentTown && (
{currentTown.name}
)} {/* NPC Proximity Interaction */} {showNPCInteraction && nearestNPC && ( )} {/* NPC Dialog */} {selectedNPC && ( setSelectedNPC(null)} onQuestsChanged={refreshHeroQuests} onHeroUpdated={handleNPCHeroUpdated} onToast={(message, color) => setToast({ message, color })} questClaimDisabled={questClaimDisabled} /> )} {/* Wandering NPC Encounter Popup */} {wanderingNPC && ( )} {/* Minimap (top-right) */} {gameState.hero && ( )} {/* Achievements Panel */} {gameState.hero && } {/* Offline Report Overlay */} {offlineReport && ( setOfflineReport(null)} /> )} {/* Connection Status Banner */} {wsEverConnected && !wsConnected && (
Reconnecting...
)} {/* Fatal connection error */} {connectionError && (
Connection Failed
{connectionError}
)}
); }