You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1299 lines
43 KiB
TypeScript

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,
getAdventureLog,
getTowns,
getTownNPCs,
getTownBuildings,
getHeroQuests,
getHeroEquipment,
claimQuest,
abandonQuest,
getAchievements,
getNearbyHeroes,
buyPotion,
healAtNPC,
requestRevive,
} from './network/api';
import type { HeroResponse, Achievement } 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,
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 } from './game/adventureLogMap';
import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage';
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 { 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<Record<BuffType, number>>,
now: number,
): Partial<Record<BuffType, number>> {
const out: Partial<Record<BuffType, number>> = {};
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<Record<BuffType, number>> {
try {
const raw = sessionStorage.getItem(buffCdStorageKey(heroId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<Record<BuffType, number>>;
return pruneCooldownEnds(parsed, Date.now());
} catch {
return {};
}
}
function saveCooldownsToStorage(heroId: number, ends: Partial<Record<BuffType, number>>): 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<Record<BuffType, BuffChargeState>>. */
function mapBuffCharges(
raw: Record<string, { remaining: number; periodEnd: string | null }> | undefined,
): Partial<Record<BuffType, BuffChargeState>> {
if (!raw) return {};
const out: Partial<Record<BuffType, BuffChargeState>> = {};
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<string, EquipmentItem> {
const out: Record<string, EquipmentItem> = {};
// 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,
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),
};
}
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<Locale>(() => 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<HTMLDivElement>(null);
const engineRef = useRef<GameEngine | null>(null);
const wsRef = useRef<GameWebSocket | null>(null);
const [gameState, setGameState] = useState<GameState>({
phase: GamePhase.Walking,
hero: null,
enemy: null,
loot: null,
lastVictoryLoot: null,
tick: 0,
serverTimeMs: 0,
routeWaypoints: null,
});
const [damages, setDamages] = useState<FloatingDamageData[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [wsEverConnected, setWsEverConnected] = useState(false);
const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState<
Partial<Record<BuffType, number>>
>({});
const [connectionError, setConnectionError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]);
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
const [needsName, setNeedsName] = useState(false);
const logIdCounter = useRef(0);
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Quest system state
const [towns, setTowns] = useState<Town[]>([]);
const townsRef = useRef<Town[]>([]);
const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]);
const [currentTown, setCurrentTown] = useState<Town | null>(null);
const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null);
const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats');
// NPC interaction state (server-driven via town_enter)
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
// Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]);
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 refreshEquipment = useCallback(() => {
const telegramId = getTelegramUserId() ?? 1;
getHeroEquipment(telegramId)
.then((eqMap) => {
const merged: Record<string, EquipmentItem> = {};
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.isCrit) {
hapticImpact('heavy');
} else {
hapticImpact('light');
}
engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150);
});
engine.init(container).then(async () => {
let shouldOpenWS = false;
try {
const telegramId = getTelegramUserId() ?? 1;
const initRes = await initHero(telegramId);
// 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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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, {
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) => {
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 === 'weapon' || l.itemType === 'armor');
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: () => {
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' });
addLogEntry(`Entered ${p.townName}`);
const npcs = p.npcs ?? [];
if (npcs.length > 0) {
const firstNPC = npcs[0]!;
setNearestNPC({
id: firstNPC.id,
name: firstNPC.name,
type: firstNPC.type as NPCData['type'],
worldX: 0,
worldY: 0,
});
setNpcInteractionDismissed(null);
}
},
onAdventureLogLine: (p) => {
addLogEntry(p.message);
},
onTownNPCVisit: (p) => {
const role =
p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel;
setToast({ message: `${role}: ${p.name}`, color: '#c9a227' });
setNearestNPC({
id: p.npcId,
name: p.name,
type: p.type as NPCData['type'],
worldX: 0,
worldY: 0,
});
setNpcInteractionDismissed(null);
},
onTownExit: () => {
setCurrentTown(null);
setNearestNPC(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') {
addLogEntry('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;
};
}, []);
// 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();
// 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<Record<BuffType, BuffChargeState>> = {
...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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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(() => {});
}
}, []);
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: 50 }), 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]);
const handleNPCHeal = useCallback((_npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1;
healAtNPC(telegramId)
.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);
addLogEntry('Declined wandering merchant');
}, [addLogEntry]);
// 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), []);
return (
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
<div style={appStyle}>
{/* PixiJS Canvas */}
<div ref={canvasRef} style={canvasContainerStyle} />
{/* React UI Overlay */}
<HUD
gameState={gameState}
onBuffActivate={handleBuffActivate}
buffCooldownEndsAt={buffCooldownEndsAt}
onUsePotion={handleUsePotion}
onHeroUpdated={handleNPCHeroUpdated}
completedQuestCount={completedQuestCount}
onOpenHeroSheet={() => {
setHeroSheetInitialTab('stats');
setHeroSheetOpen(true);
}}
/>
{gameState.hero && (
<HeroSheetModal
open={heroSheetOpen}
onClose={() => setHeroSheetOpen(false)}
initialTab={heroSheetInitialTab}
hero={gameState.hero}
nowMs={sheetNowMs}
equipment={gameState.hero.equipment ?? {}}
logEntries={logEntries}
quests={heroQuests}
onQuestClaim={handleQuestClaim}
onQuestAbandon={handleQuestAbandon}
/>
)}
{/* Floating Damage Numbers */}
<FloatingDamage damages={damages} />
{/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />}
{/* Death Screen */}
<DeathScreen
visible={gameState.phase === GamePhase.Dead}
onRevive={handleRevive}
revivesRemaining={
gameState.hero?.subscriptionActive
? undefined
: Math.max(0, 2 - (gameState.hero?.reviveCount ?? 0))
}
/>
{/* Toast Notification */}
{toast && (
<GameToast
message={toast.message}
color={toast.color}
onDone={dismissToast}
/>
)}
{/* Town Name Indicator */}
{currentTown && (
<div
style={{
position: 'absolute',
top: 48,
left: '50%',
transform: 'translateX(-50%)',
padding: '3px 14px',
borderRadius: 6,
backgroundColor: 'rgba(218, 165, 32, 0.15)',
border: '1px solid rgba(218, 165, 32, 0.3)',
color: '#daa520',
fontSize: 11,
fontWeight: 600,
zIndex: 30,
pointerEvents: 'none',
textShadow: '0 0 6px rgba(218, 165, 32, 0.3)',
}}
>
{currentTown.name}
</div>
)}
{/* NPC Proximity Interaction */}
{showNPCInteraction && nearestNPC && (
<NPCInteraction
npc={nearestNPC}
heroGold={gameState.hero?.gold ?? 0}
onViewQuests={handleNPCViewQuests}
onBuyPotion={handleNPCBuyPotion}
onHeal={handleNPCHeal}
onDismiss={handleNPCInteractionDismiss}
/>
)}
{/* NPC Dialog */}
{selectedNPC && (
<NPCDialog
npc={selectedNPC}
heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0}
onClose={() => setSelectedNPC(null)}
onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })}
/>
)}
{/* Wandering NPC Encounter Popup */}
{wanderingNPC && (
<WanderingNPCPopup
npcName={wanderingNPC.npcName}
message={wanderingNPC.message}
cost={wanderingNPC.cost}
heroGold={gameState.hero?.gold ?? 0}
onAccept={handleWanderingAccept}
onDecline={handleWanderingDecline}
/>
)}
{/* Minimap (top-right) */}
{gameState.hero && (
<Minimap
heroX={gameState.hero.position.x}
heroY={gameState.hero.position.y}
towns={towns}
routeWaypoints={gameState.routeWaypoints}
/>
)}
{/* Achievements Panel */}
{gameState.hero && <AchievementsPanel achievements={achievements} />}
{/* Offline Report Overlay */}
{offlineReport && (
<OfflineReport
monstersKilled={offlineReport.monstersKilled}
xpGained={offlineReport.xpGained}
goldGained={offlineReport.goldGained}
levelsGained={offlineReport.levelsGained}
onDismiss={() => setOfflineReport(null)}
/>
)}
{/* Connection Status Banner */}
{wsEverConnected && !wsConnected && (
<div style={connectionBanner}>
Reconnecting...
</div>
)}
{/* Fatal connection error */}
{connectionError && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.85)', color: '#fff', zIndex: 1000,
fontFamily: 'system-ui, sans-serif', textAlign: 'center', padding: 24,
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}>&#x26A0;</div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Connection Failed</div>
<div style={{ fontSize: 14, opacity: 0.8, marginBottom: 24 }}>{connectionError}</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 28px', fontSize: 14, fontWeight: 600,
border: 'none', borderRadius: 8, cursor: 'pointer',
backgroundColor: '#4a90d9', color: '#fff',
}}
>
Retry
</button>
</div>
)}
</div>
</I18nContext.Provider>
);
}