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.
1714 lines
58 KiB
TypeScript
1714 lines
58 KiB
TypeScript
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
|
|
import { GameEngine } from './game/engine';
|
|
import {
|
|
GamePhase,
|
|
BuffType,
|
|
type GameState,
|
|
type ActiveBuff,
|
|
type NPCData,
|
|
type AttackPayload,
|
|
type EnemyRegenPayload,
|
|
type NearbyHeroData,
|
|
} from './game/types';
|
|
import type { NPCEncounterEvent } from './game/types';
|
|
import { GameWebSocket } from './network/websocket';
|
|
import {
|
|
wireWSHandler,
|
|
sendActivateBuff,
|
|
sendUsePotion,
|
|
sendRevive,
|
|
sendNPCAlmsAccept,
|
|
sendNPCAlmsDecline,
|
|
sendTownTourNPCDialogClosed,
|
|
sendTownTourNPCInteractionOpened,
|
|
sendTownTourNPCInteractionClosed,
|
|
sendRequestNearbyHeroes,
|
|
buildLootFromCombatEnd,
|
|
buildMerchantLootDrop,
|
|
} from './game/ws-handler';
|
|
import {
|
|
initHero,
|
|
ackChangelog,
|
|
getAdventureLog,
|
|
getTowns,
|
|
getTownNPCs,
|
|
getTownBuildings,
|
|
getHeroQuests,
|
|
getHeroEquipment,
|
|
claimQuest,
|
|
abandonQuest,
|
|
getAchievements,
|
|
requestRevive,
|
|
defaultNpcShopBundle,
|
|
npcShopCostsFromInit,
|
|
offlineReportHasActivity,
|
|
} from './network/api';
|
|
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
|
|
import type {
|
|
AdventureLogEntry,
|
|
Town,
|
|
HeroQuest,
|
|
NPC,
|
|
TownData,
|
|
EquipmentItem,
|
|
BuildingData,
|
|
TownTourPhasePayload,
|
|
} 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,
|
|
appendAdventureLogRawRow,
|
|
buildAdventureLogEntriesFromRaw,
|
|
type AdventureLogRawRow,
|
|
} from './game/adventureLogMap';
|
|
import {
|
|
isAdventureLogCombatCode,
|
|
isAdventureLogEncounterCode,
|
|
parseAdventureLogLine,
|
|
shouldSuppressThoughtBubblePayload,
|
|
} from './game/adventureLogMarkers';
|
|
import { formatAdventureLogPayload, formatClientLogLine } from './game/adventureLogFormat';
|
|
import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels';
|
|
import type { AdventureLogLinePayload } from './game/types';
|
|
import { HUD } from './ui/HUD';
|
|
import { DeathScreen } from './ui/DeathScreen';
|
|
import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
|
|
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 { HeroMeetPanel } from './ui/HeroMeetPanel';
|
|
import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n';
|
|
import { adventureLogTemplate, randomRoadsideThoughtLine } from './i18n/loadLocales';
|
|
|
|
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));
|
|
}
|
|
|
|
function normalizeHeroModelVariant(v: unknown): number {
|
|
return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 2 ? v : 0;
|
|
}
|
|
|
|
/** 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',
|
|
subtype: item.subtype,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
function townTourPayloadToNPCData(p: TownTourPhasePayload, loc: Locale, town: Town | null): NPCData {
|
|
const tw = town ?? undefined;
|
|
const displayName = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName ?? '') : (p.npcName ?? '');
|
|
return {
|
|
id: p.npcId ?? 0,
|
|
name: displayName,
|
|
nameKey: p.npcNameKey,
|
|
type: (p.npcType ?? 'merchant') as NPCData['type'],
|
|
worldX: p.worldX ?? 0,
|
|
worldY: p.worldY ?? 0,
|
|
townId: p.townId,
|
|
townLevelMin: tw?.levelMin ?? 1,
|
|
townLevelMax: tw?.levelMax ?? 1,
|
|
};
|
|
}
|
|
|
|
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
|
|
function townToTownData(
|
|
town: Town,
|
|
npcs?: NPC[],
|
|
buildings?: BuildingData[],
|
|
locale: Locale = 'en',
|
|
): TownData {
|
|
const npcData: NPCData[] | undefined = npcs?.map((n) => ({
|
|
id: n.id,
|
|
name: n.nameKey ? npcLabel(locale, n.nameKey, n.name) : n.name,
|
|
nameKey: n.nameKey,
|
|
type: n.type,
|
|
worldX: town.worldX + n.offsetX,
|
|
worldY: town.worldY + n.offsetY,
|
|
buildingId: n.buildingId,
|
|
townId: town.id,
|
|
townLevelMin: town.levelMin,
|
|
townLevelMax: town.levelMax,
|
|
}));
|
|
return {
|
|
id: town.id,
|
|
name: town.nameKey ? townLabel(locale, town.nameKey, town.name) : town.name,
|
|
nameKey: town.nameKey,
|
|
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,
|
|
modelVariant: normalizeHeroModelVariant(res.modelVariant),
|
|
hp: res.hp,
|
|
maxHp: res.maxHp,
|
|
position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
|
|
serverActivityState: res.state,
|
|
restKind: res.restKind,
|
|
excursionPhase: res.excursionPhase,
|
|
excursionKind:
|
|
res.excursionKind === 'roadside' ||
|
|
res.excursionKind === 'adventure' ||
|
|
res.excursionKind === 'town' ||
|
|
res.excursionKind === 'hero_meet'
|
|
? (res.excursionKind as HeroState['excursionKind'])
|
|
: undefined,
|
|
townTourPhase: res.townTourPhase,
|
|
townTourNpcId: res.townTourNpcId,
|
|
townTourExitPending: res.townTourExitPending,
|
|
attackSpeed: res.attackSpeed ?? res.speed,
|
|
damage: res.attackPower ?? res.attack,
|
|
defense: res.defensePower ?? res.defense,
|
|
weaponType: (res.gear?.main_hand?.subtype ?? res.weapon?.type ?? 'sword') as HeroState['weaponType'],
|
|
weaponName: res.gear?.main_hand?.name ?? res.weapon?.name ?? '',
|
|
weaponRarity: (res.gear?.main_hand?.rarity ?? res.weapon?.rarity ?? 'common') as Rarity,
|
|
weaponIlvl: res.gear?.main_hand?.ilvl ?? res.weapon?.ilvl,
|
|
armorType: (res.gear?.chest?.subtype ?? res.armor?.type ?? 'medium') as HeroState['armorType'],
|
|
armorName: res.gear?.chest?.name ?? res.armor?.name ?? '',
|
|
armorRarity: (res.gear?.chest?.rarity ?? res.armor?.rarity ?? 'common') as Rarity,
|
|
armorIlvl: res.gear?.chest?.ilvl ?? 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<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 [combatEvents, setCombatEvents] = useState<CombatOverlayEvent[]>([]);
|
|
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);
|
|
type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string };
|
|
const pendingChangelogRef = useRef<ChangelogOpen | null>(null);
|
|
const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
|
|
const logIdCounter = useRef(0);
|
|
const logRawRef = useRef<AdventureLogRawRow[]>([]);
|
|
const i18nForLogRef = useRef({ locale, tr: translations });
|
|
i18nForLogRef.current = { locale, tr: translations };
|
|
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
useEffect(() => {
|
|
engineRef.current?.refreshRandomRestThoughtForLocale();
|
|
}, [locale]);
|
|
|
|
// 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 (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap).
|
|
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
|
|
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
|
|
const [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(null);
|
|
|
|
const townTourDialogWs = useMemo(
|
|
() => ({
|
|
onInteractionOpened: () => {
|
|
const w = wsRef.current;
|
|
if (w) sendTownTourNPCInteractionOpened(w);
|
|
},
|
|
onDialogAndInteractionClosed: () => {
|
|
const w = wsRef.current;
|
|
if (!w) return;
|
|
sendTownTourNPCDialogClosed(w);
|
|
sendTownTourNPCInteractionClosed(w);
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
// Wandering NPC encounter state
|
|
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
|
|
|
|
const [heroMeetPanel, setHeroMeetPanel] = useState<{
|
|
partnerName: string;
|
|
anySideOnline: boolean;
|
|
maxChars: number;
|
|
} | null>(null);
|
|
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
|
|
// Achievements
|
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
|
const prevAchievementsRef = useRef<Achievement[]>([]);
|
|
|
|
const sheetNowMs = useUiClock(100);
|
|
|
|
const appendLogPayload = useCallback((p: AdventureLogLinePayload) => {
|
|
const { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
logIdCounter.current += 1;
|
|
const id = logIdCounter.current;
|
|
logRawRef.current = appendAdventureLogRawRow(
|
|
logRawRef.current,
|
|
{ id, message: p.message ?? '', timestamp: Date.now(), event: p.event },
|
|
() => 0,
|
|
);
|
|
setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));
|
|
|
|
const thoughtText = formatAdventureLogPayload(loc, bundle, p);
|
|
const eng = engineRef.current;
|
|
if (thoughtText && eng && !shouldSuppressThoughtBubblePayload(p)) {
|
|
eng.applyAdventureLogLine(thoughtText);
|
|
}
|
|
|
|
if (isAdventureLogEncounterCode(p.event?.code) && thoughtText) {
|
|
setCombatLogLines([thoughtText]);
|
|
} else {
|
|
const parsed = parseAdventureLogLine(p.message ?? '');
|
|
if (parsed.type === 'encounter') {
|
|
setCombatLogLines([parsed.title]);
|
|
} else if (parsed.type === 'battle') {
|
|
setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
|
|
} else if (isAdventureLogCombatCode(p.event?.code) && thoughtText) {
|
|
setCombatLogLines((prev) => [...prev, thoughtText].slice(-5));
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const appendLogClientMessage = useCallback((rawMessage: string) => {
|
|
const { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
logIdCounter.current += 1;
|
|
const id = logIdCounter.current;
|
|
logRawRef.current = appendAdventureLogRawRow(
|
|
logRawRef.current,
|
|
{ id, message: rawMessage, timestamp: Date.now() },
|
|
() => 0,
|
|
);
|
|
setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));
|
|
const parsed = parseAdventureLogLine(rawMessage);
|
|
if (parsed.type === 'encounter') {
|
|
setCombatLogLines([parsed.title]);
|
|
} else if (parsed.type === 'battle') {
|
|
setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle));
|
|
}, [locale, translations]);
|
|
|
|
useEffect(() => {
|
|
const eng = engineRef.current;
|
|
if (!eng || towns.length === 0) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const townNPCMap = new Map<number, NPC[]>();
|
|
const townBuildingMap = new Map<number, BuildingData[]>();
|
|
try {
|
|
const [npcResults, buildingResults] = await Promise.all([
|
|
Promise.allSettled(towns.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
|
|
Promise.allSettled(towns.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 */
|
|
}
|
|
if (cancelled) return;
|
|
const loc = locale;
|
|
const townDataList = towns.map((town) =>
|
|
townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
|
|
);
|
|
eng.setTowns(townDataList);
|
|
const allNPCs: NPCData[] = [];
|
|
for (const td of townDataList) {
|
|
if (td.npcs) allNPCs.push(...td.npcs);
|
|
}
|
|
eng.setNPCs(allNPCs);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [locale, towns]);
|
|
|
|
const appendCombatEvent = useCallback((evt: CombatOverlayEvent) => {
|
|
setCombatEvents((prev) => [...prev, evt]);
|
|
}, []);
|
|
|
|
const clearCombatEvents = useCallback(() => {
|
|
setCombatEvents([]);
|
|
}, []);
|
|
|
|
const expireCombatEvent = useCallback((id: number) => {
|
|
setCombatEvents((prev) => prev.filter((evt) => evt.id !== id));
|
|
}, []);
|
|
|
|
const handleCombatAttack = useCallback((p: AttackPayload) => {
|
|
if (p.source === 'potion') {
|
|
return;
|
|
}
|
|
|
|
const defender: CombatOverlayEvent['target'] =
|
|
p.source === 'enemy' ? 'hero' : 'enemy';
|
|
const isBlocked = p.outcome === 'block';
|
|
const isEvaded = p.outcome === 'dodge';
|
|
const isStunned = p.outcome === 'stun';
|
|
const isCrit = Boolean(p.isCrit);
|
|
|
|
if (isStunned) {
|
|
appendCombatEvent({
|
|
id: Date.now() + Math.random(),
|
|
kind: 'stunned',
|
|
target: 'hero',
|
|
value: 0,
|
|
createdAt: performance.now(),
|
|
});
|
|
} else if (isBlocked || isEvaded) {
|
|
appendCombatEvent({
|
|
id: Date.now() + Math.random(),
|
|
kind: isBlocked ? 'blocked' : 'evaded',
|
|
target: defender,
|
|
value: 0,
|
|
createdAt: performance.now(),
|
|
});
|
|
} else {
|
|
appendCombatEvent({
|
|
id: Date.now() + Math.random(),
|
|
kind: 'damage',
|
|
target: defender,
|
|
value: p.damage,
|
|
isCrit,
|
|
createdAt: performance.now(),
|
|
});
|
|
}
|
|
|
|
const engine = engineRef.current;
|
|
if (!engine) return;
|
|
|
|
const isDotLike = p.source === 'dot' || p.source === 'summon';
|
|
if (isDotLike) return;
|
|
|
|
if (isStunned) {
|
|
hapticImpact('light');
|
|
engine.camera.shake(3, 120);
|
|
return;
|
|
}
|
|
|
|
if (isBlocked || isEvaded) {
|
|
hapticImpact('light');
|
|
engine.camera.shake(3, 120);
|
|
return;
|
|
}
|
|
|
|
if (isCrit) {
|
|
hapticImpact('heavy');
|
|
engine.camera.shake(8, 250);
|
|
} else {
|
|
hapticImpact('light');
|
|
engine.camera.shake(4, 150);
|
|
}
|
|
}, [appendCombatEvent]);
|
|
|
|
const handleEnemyRegen = useCallback((p: EnemyRegenPayload) => {
|
|
if (p.amount <= 0) return;
|
|
appendCombatEvent({
|
|
id: Date.now() + Math.random(),
|
|
kind: 'regen',
|
|
target: 'enemy',
|
|
value: p.amount,
|
|
createdAt: performance.now(),
|
|
});
|
|
}, [appendCombatEvent]);
|
|
|
|
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.setRestThoughtPicker(() =>
|
|
randomRoadsideThoughtLine(i18nForLogRef.current.locale),
|
|
);
|
|
|
|
engine.onStateChange((state) => {
|
|
setGameState(state);
|
|
});
|
|
|
|
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<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 loc = i18nForLogRef.current.locale;
|
|
const townDataList = t.map((town) =>
|
|
townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
|
|
);
|
|
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 && offlineReportHasActivity(initRes.offlineReport)) {
|
|
const r = initRes.offlineReport;
|
|
console.info(
|
|
`[Offline] ${r.message ?? ''} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels, deaths ${r.deaths ?? 0}, revives ${r.revives ?? 0}`,
|
|
);
|
|
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 loc = i18nForLogRef.current.locale;
|
|
const townDataList = t.map((town) =>
|
|
townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
|
|
);
|
|
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'));
|
|
|
|
// Fetch adventure log (same source as server DB; response shape { log: [...] })
|
|
try {
|
|
const serverLog = await getAdventureLog(telegramId, 50);
|
|
const { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle);
|
|
logRawRef.current = rawRows;
|
|
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([]);
|
|
clearCombatEvents();
|
|
},
|
|
onCombatAttack: handleCombatAttack,
|
|
onEnemyRegen: handleEnemyRegen,
|
|
|
|
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 bundle = i18nForLogRef.current.tr;
|
|
setCombatLogLines([]);
|
|
clearCombatEvents();
|
|
const loot = buildLootFromCombatEnd(p);
|
|
engine.applyLoot(loot);
|
|
hapticNotification('success');
|
|
|
|
const parts: string[] = [];
|
|
if (p.xpGained > 0) parts.push(t(bundle.toastGainedXp, { xp: p.xpGained }));
|
|
if (p.goldGained > 0) parts.push(t(bundle.toastGainedGold, { gold: p.goldGained }));
|
|
const equipDrop = (p.loot ?? []).find(
|
|
(l) => l.itemType !== 'gold' && l.itemType !== 'potion',
|
|
);
|
|
if (equipDrop?.name) parts.push(t(bundle.toastFoundItem, { name: equipDrop.name }));
|
|
// Victory line comes from server adventure log (Defeated …) + WS adventure_log_line
|
|
|
|
if (p.leveledUp && p.newLevel) {
|
|
setToast({ message: t(bundle.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: t(bundle.achievementUnlockedToast, { title: 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([]);
|
|
clearCombatEvents();
|
|
setToast({ message: i18nForLogRef.current.tr.heroRevived, color: '#44cc44' });
|
|
// "Hero revived" comes from server log + WS
|
|
},
|
|
|
|
onBuffApplied: (_p) => {
|
|
// Buff activation comes from server log + WS
|
|
},
|
|
|
|
onTownEnter: (p) => {
|
|
const { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
const town = townsRef.current.find((t) => t.id === p.townId) ?? null;
|
|
setCurrentTown(town);
|
|
const townDisp = townLabel(loc, p.townNameKey, p.townName);
|
|
setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' });
|
|
appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp }));
|
|
setNearestNPC(null);
|
|
setSelectedNPC(null);
|
|
setNpcInteractionDismissed(null);
|
|
setTownTourLastPayload(null);
|
|
},
|
|
|
|
onAdventureLogLine: (p) => {
|
|
appendLogPayload(p);
|
|
},
|
|
|
|
onTownNPCVisit: () => {
|
|
// Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler).
|
|
},
|
|
|
|
onTownTourPhase: (p) => {
|
|
setTownTourLastPayload(p);
|
|
const ph = p.phase;
|
|
if (ph === 'npc_welcome' || ph === 'npc_service') {
|
|
setNpcInteractionDismissed(null);
|
|
}
|
|
if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') {
|
|
setSelectedNPC(null);
|
|
}
|
|
},
|
|
|
|
onTownTourServiceEnd: () => {
|
|
setTownTourLastPayload(null);
|
|
setSelectedNPC(null);
|
|
},
|
|
|
|
onTownExit: () => {
|
|
setCurrentTown(null);
|
|
setNearestNPC(null);
|
|
setTownTourLastPayload(null);
|
|
},
|
|
|
|
onNPCEncounter: (p) => {
|
|
const loc = i18nForLogRef.current.locale;
|
|
const name = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName) : p.npcName;
|
|
const msg = p.dialogueKey
|
|
? dialogueText(loc, p.dialogueKey, p.dialogue ?? `${name} approaches!`)
|
|
: (p.dialogue ?? `${name} approaches!`);
|
|
const npcEvent: NPCEncounterEvent = {
|
|
type: 'npc_event',
|
|
npcName: name,
|
|
npcNameKey: p.npcNameKey,
|
|
dialogueKey: p.dialogueKey,
|
|
message: msg,
|
|
cost: p.cost,
|
|
};
|
|
setWanderingNPC(npcEvent);
|
|
},
|
|
|
|
onNPCEncounterEnd: (p) => {
|
|
if (p.reason === 'timeout') {
|
|
appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'merchantMovedOn'));
|
|
}
|
|
setWanderingNPC(null);
|
|
},
|
|
|
|
onLevelUp: (p) => {
|
|
setToast({ message: t(i18nForLogRef.current.tr.levelUp, { level: p.newLevel }), color: '#ffd700' });
|
|
hapticNotification('success');
|
|
// Level-up lines come from server log + WS
|
|
},
|
|
|
|
onEquipmentChange: (p) => {
|
|
const bundle = i18nForLogRef.current.tr;
|
|
setToast({ message: t(bundle.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' });
|
|
// Equipment line comes from server log + WS
|
|
refreshEquipment();
|
|
},
|
|
|
|
onPotionCollected: (p) => {
|
|
const bundle = i18nForLogRef.current.tr;
|
|
setToast({ message: t(bundle.potionsCollected, { count: p.count }), color: '#44cc44' });
|
|
},
|
|
|
|
onQuestProgress: (p) => {
|
|
const bundle = i18nForLogRef.current.tr;
|
|
setHeroQuests((prev) =>
|
|
prev.map((hq) =>
|
|
hq.questId === p.questId
|
|
? { ...hq, progress: p.current }
|
|
: hq,
|
|
),
|
|
);
|
|
if (p.title) {
|
|
setToast({
|
|
message: t(bundle.questProgress, { title: p.title, current: p.current, target: p.target }),
|
|
color: '#44aaff',
|
|
});
|
|
}
|
|
},
|
|
|
|
onQuestComplete: (p) => {
|
|
const bundle = i18nForLogRef.current.tr;
|
|
setHeroQuests((prev) =>
|
|
prev.map((hq) =>
|
|
hq.questId === p.questId
|
|
? { ...hq, status: 'completed' as const }
|
|
: hq,
|
|
),
|
|
);
|
|
setToast({ message: t(bundle.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();
|
|
},
|
|
|
|
onHeroMeetStart: (p) => {
|
|
const partner: NearbyHeroData = {
|
|
id: p.partner.id,
|
|
name: p.partner.name,
|
|
level: p.partner.level,
|
|
modelVariant: normalizeHeroModelVariant(p.partner.modelVariant),
|
|
positionX: p.partner.positionX,
|
|
positionY: p.partner.positionY,
|
|
};
|
|
engine.setHeroMeetOverlay(partner);
|
|
const phase = p.meetPhase;
|
|
const showChat = phase === 'meet' || phase === undefined;
|
|
if (showChat) {
|
|
setHeroMeetPanel({
|
|
partnerName: p.partner.name,
|
|
anySideOnline: p.anySideOnline,
|
|
maxChars: 140,
|
|
});
|
|
} else {
|
|
setHeroMeetPanel(null);
|
|
}
|
|
},
|
|
|
|
onHeroMeetLine: (p) => {
|
|
const loc = i18nForLogRef.current.locale;
|
|
let text = (p.text ?? '').trim();
|
|
if (p.kind === 'scripted' && p.lineKey) {
|
|
text = (adventureLogTemplate(loc, p.lineKey) ?? p.lineKey).trim();
|
|
}
|
|
if (!text) return;
|
|
engine.applyHeroMeetChatLine(p.fromHeroId, p.kind, text);
|
|
},
|
|
|
|
onHeroMeetEnd: (p) => {
|
|
setHeroMeetPanel(null);
|
|
const linger = p.partnerLingerMs != null && p.partnerLingerMs > 0 ? p.partnerLingerMs : 20000;
|
|
engine.endHeroMeetOverlayLinger(linger);
|
|
},
|
|
});
|
|
|
|
// ---- 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;
|
|
};
|
|
}, []);
|
|
|
|
// WS-driven nearby heroes: request list + subscribe to updates.
|
|
useEffect(() => {
|
|
const ws = wsRef.current;
|
|
if (!wsConnected || !ws) {
|
|
if (nearbyIntervalRef.current) {
|
|
clearInterval(nearbyIntervalRef.current);
|
|
nearbyIntervalRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
const request = (): void => {
|
|
sendRequestNearbyHeroes(ws);
|
|
};
|
|
request();
|
|
const interval = setInterval(request, 5000);
|
|
nearbyIntervalRef.current = interval;
|
|
return () => {
|
|
clearInterval(interval);
|
|
if (nearbyIntervalRef.current === interval) {
|
|
nearbyIntervalRef.current = null;
|
|
}
|
|
};
|
|
}, [wsConnected]);
|
|
|
|
// 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 * 5) / 3);
|
|
break;
|
|
case BuffType.PowerPotion:
|
|
damage = Math.round(damage * 2);
|
|
break;
|
|
case BuffType.WarCry:
|
|
attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100;
|
|
break;
|
|
case BuffType.Heal:
|
|
hp = Math.min(maxHp, hp + Math.round(maxHp / 3));
|
|
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 loc = i18nForLogRef.current.locale;
|
|
const townDataList = t.map((town) =>
|
|
townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc),
|
|
);
|
|
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 { locale: loc, tr: bundle } = i18nForLogRef.current;
|
|
const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle);
|
|
logRawRef.current = rawRows;
|
|
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: npc.townId,
|
|
name: npc.name,
|
|
nameKey: npc.nameKey,
|
|
type: npc.type,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
townLevelMin: npc.townLevelMin,
|
|
townLevelMax: npc.townLevelMax,
|
|
};
|
|
setSelectedNPC(matchedNPC);
|
|
setNpcInteractionDismissed(npc.id);
|
|
}, []);
|
|
|
|
const handleTownTourInteractionDismiss = useCallback(() => {
|
|
const w = wsRef.current;
|
|
if (!w) return;
|
|
const ph = townTourLastPayload?.phase;
|
|
if (ph === 'npc_welcome') {
|
|
sendTownTourNPCDialogClosed(w);
|
|
} else if (ph === 'npc_service') {
|
|
sendTownTourNPCInteractionClosed(w);
|
|
}
|
|
}, [townTourLastPayload?.phase]);
|
|
|
|
// ---- 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);
|
|
appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant'));
|
|
}, [appendLogClientMessage]);
|
|
|
|
const heroOnTownTour = gameState.hero?.excursionKind === 'town';
|
|
|
|
const townTourChipActive =
|
|
(townTourLastPayload?.phase === 'npc_welcome' || townTourLastPayload?.phase === 'npc_service') &&
|
|
(townTourLastPayload.npcId ?? 0) > 0;
|
|
|
|
const townTourInteractionNPC =
|
|
townTourChipActive && townTourLastPayload
|
|
? townTourPayloadToNPCData(townTourLastPayload, locale, currentTown)
|
|
: null;
|
|
|
|
const legacyProximityNPC = !heroOnTownTour ? nearestNPC : null;
|
|
|
|
const interactionNpc = townTourInteractionNPC ?? legacyProximityNPC;
|
|
|
|
const showNPCInteraction =
|
|
interactionNpc != null &&
|
|
npcInteractionDismissed !== interactionNpc.id &&
|
|
(gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
|
|
!selectedNPC;
|
|
|
|
const dialogNpc = selectedNPC;
|
|
|
|
const handleNPCInteractionDismiss = useCallback(() => {
|
|
if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id);
|
|
}, [interactionNpc]);
|
|
|
|
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 (
|
|
<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);
|
|
}}
|
|
/>
|
|
|
|
{heroMeetPanel && wsRef.current ? (
|
|
<HeroMeetPanel
|
|
partnerName={heroMeetPanel.partnerName}
|
|
anySideOnline={heroMeetPanel.anySideOnline}
|
|
ws={wsRef.current}
|
|
maxChars={heroMeetPanel.maxChars}
|
|
/>
|
|
) : null}
|
|
|
|
{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}
|
|
questClaimDisabled={questClaimDisabled}
|
|
/>
|
|
)}
|
|
|
|
{/* Combat overlay */}
|
|
<CombatOverlay events={combatEvents} onExpire={expireCombatEvent} />
|
|
|
|
<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} />}
|
|
|
|
{changelogOpen && (
|
|
<ChangelogModal
|
|
title={changelogOpen.payload.title}
|
|
items={changelogOpen.payload.items}
|
|
serverVersion={changelogOpen.serverVersion}
|
|
onDismiss={handleDismissChangelog}
|
|
/>
|
|
)}
|
|
|
|
{/* Death Screen */}
|
|
<DeathScreen
|
|
visible={gameState.phase === GamePhase.Dead}
|
|
onRevive={handleRevive}
|
|
subscriptionUnlimited={!!gameState.hero?.subscriptionActive}
|
|
revivesRemaining={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>
|
|
)}
|
|
|
|
{/* Town tour service chip or legacy proximity NPC */}
|
|
{showNPCInteraction && interactionNpc && (
|
|
<NPCInteraction
|
|
npc={interactionNpc}
|
|
onViewQuests={handleNPCViewQuests}
|
|
onOpenServiceDialog={handleNPCViewQuests}
|
|
onDismiss={handleNPCInteractionDismiss}
|
|
onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
|
|
{dialogNpc && (
|
|
<NPCDialog
|
|
npc={dialogNpc}
|
|
heroQuests={heroQuests}
|
|
heroGold={gameState.hero?.gold ?? 0}
|
|
potionCost={npcShopCosts.potionCost}
|
|
healCost={npcShopCosts.healCost}
|
|
getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }}
|
|
onClose={() => {
|
|
setNpcInteractionDismissed(dialogNpc.id);
|
|
setSelectedNPC(null);
|
|
}}
|
|
onQuestsChanged={refreshHeroQuests}
|
|
onHeroUpdated={handleNPCHeroUpdated}
|
|
onToast={(message, color) => setToast({ message, color })}
|
|
questClaimDisabled={questClaimDisabled}
|
|
townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* 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}
|
|
deaths={offlineReport.deaths}
|
|
revives={offlineReport.revives}
|
|
loot={offlineReport.loot}
|
|
message={offlineReport.message}
|
|
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>
|
|
);
|
|
}
|