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.
1366 lines
46 KiB
TypeScript
1366 lines
46 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,
|
|
defaultNpcShopCosts,
|
|
npcShopCostsFromInit,
|
|
} 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,
|
|
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 { 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,
|
|
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),
|
|
};
|
|
}
|
|
|
|
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[]>([]);
|
|
/** Live combat narration (mirrors prefixed adventure log lines). */
|
|
const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
|
|
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
|
|
const [needsName, setNeedsName] = useState(false);
|
|
const logIdCounter = useRef(0);
|
|
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);
|
|
/** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
|
|
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | null>(null);
|
|
|
|
// Wandering NPC encounter state
|
|
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
|
|
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts);
|
|
// Achievements
|
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
|
const prevAchievementsRef = useRef<Achievement[]>([]);
|
|
|
|
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<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.kind === 'damage') {
|
|
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);
|
|
setNpcShopCosts(npcShopCostsFromInit(initRes));
|
|
|
|
// 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, {
|
|
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 === '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: () => {
|
|
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();
|
|
|
|
// 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: 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), []);
|
|
|
|
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} />
|
|
|
|
<CombatLogPanel
|
|
visible={
|
|
gameState.phase === GamePhase.Fighting || gameState.phase === GamePhase.Dead
|
|
}
|
|
lines={combatLogLines}
|
|
anchor={gameState.enemyOnScreenRight !== false ? 'left' : 'right'}
|
|
/>
|
|
|
|
{/* Name Entry Screen */}
|
|
{needsName && <NameEntryScreen onNameSet={handleNameSet} />}
|
|
|
|
{/* 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}
|
|
potionCost={npcShopCosts.potionCost}
|
|
healCost={npcShopCosts.healCost}
|
|
onViewQuests={handleNPCViewQuests}
|
|
onBuyPotion={handleNPCBuyPotion}
|
|
onHeal={handleNPCHeal}
|
|
onDismiss={handleNPCInteractionDismiss}
|
|
/>
|
|
)}
|
|
|
|
{/* NPC Dialog */}
|
|
{selectedNPC && (
|
|
<NPCDialog
|
|
npc={selectedNPC}
|
|
heroQuests={heroQuests}
|
|
heroGold={gameState.hero?.gold ?? 0}
|
|
potionCost={npcShopCosts.potionCost}
|
|
healCost={npcShopCosts.healCost}
|
|
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 }}>⚠</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>
|
|
);
|
|
}
|