|
|
|
|
@ -1,4 +1,4 @@
|
|
|
|
|
import { useEffect, useRef, useState, useCallback, type CSSProperties } from 'react';
|
|
|
|
|
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';
|
|
|
|
|
@ -11,6 +11,7 @@ import {
|
|
|
|
|
sendNPCAlmsAccept,
|
|
|
|
|
sendNPCAlmsDecline,
|
|
|
|
|
buildLootFromCombatEnd,
|
|
|
|
|
buildMerchantLootDrop,
|
|
|
|
|
} from './game/ws-handler';
|
|
|
|
|
import {
|
|
|
|
|
ApiError,
|
|
|
|
|
@ -24,13 +25,11 @@ import {
|
|
|
|
|
abandonQuest,
|
|
|
|
|
getAchievements,
|
|
|
|
|
getNearbyHeroes,
|
|
|
|
|
getDailyTasks,
|
|
|
|
|
claimDailyTask,
|
|
|
|
|
buyPotion,
|
|
|
|
|
healAtNPC,
|
|
|
|
|
requestRevive,
|
|
|
|
|
} from './network/api';
|
|
|
|
|
import type { HeroResponse, Achievement, DailyTaskResponse } from './network/api';
|
|
|
|
|
import type { HeroResponse, Achievement } from './network/api';
|
|
|
|
|
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem } from './game/types';
|
|
|
|
|
import type { OfflineReport as OfflineReportData } from './network/api';
|
|
|
|
|
import {
|
|
|
|
|
@ -42,20 +41,21 @@ import {
|
|
|
|
|
import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram';
|
|
|
|
|
import { Rarity } from './game/types';
|
|
|
|
|
import type { HeroState, BuffChargeState } from './game/types';
|
|
|
|
|
import { useUiClock } from './hooks/useUiClock';
|
|
|
|
|
import { adventureEntriesFromServerLog } from './game/adventureLogMap';
|
|
|
|
|
import { HUD } from './ui/HUD';
|
|
|
|
|
import { DeathScreen } from './ui/DeathScreen';
|
|
|
|
|
import { FloatingDamage } from './ui/FloatingDamage';
|
|
|
|
|
import { GameToast } from './ui/GameToast';
|
|
|
|
|
import { AdventureLog } from './ui/AdventureLog';
|
|
|
|
|
import { OfflineReport } from './ui/OfflineReport';
|
|
|
|
|
import { QuestLog } from './ui/QuestLog';
|
|
|
|
|
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
|
|
|
|
|
import { NPCDialog } from './ui/NPCDialog';
|
|
|
|
|
import { NameEntryScreen } from './ui/NameEntryScreen';
|
|
|
|
|
import { DailyTasks } from './ui/DailyTasks';
|
|
|
|
|
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%',
|
|
|
|
|
@ -152,8 +152,13 @@ function mapEquipment(
|
|
|
|
|
): Record<string, EquipmentItem> {
|
|
|
|
|
const out: Record<string, EquipmentItem> = {};
|
|
|
|
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
for (const [slot, item] of Object.entries(raw)) {
|
|
|
|
|
// 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,
|
|
|
|
|
@ -255,11 +260,34 @@ function heroResponseToState(res: HeroResponse): HeroState {
|
|
|
|
|
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);
|
|
|
|
|
@ -295,7 +323,8 @@ export function App() {
|
|
|
|
|
const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]);
|
|
|
|
|
const [currentTown, setCurrentTown] = useState<Town | null>(null);
|
|
|
|
|
const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null);
|
|
|
|
|
const [questLogOpen, setQuestLogOpen] = useState(false);
|
|
|
|
|
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);
|
|
|
|
|
@ -303,12 +332,12 @@ export function App() {
|
|
|
|
|
|
|
|
|
|
// Wandering NPC encounter state
|
|
|
|
|
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
|
|
|
|
|
// Daily tasks (backend-driven)
|
|
|
|
|
const [dailyTasks, setDailyTasks] = useState<DailyTaskResponse[]>([]);
|
|
|
|
|
// Achievements
|
|
|
|
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
|
|
|
|
const prevAchievementsRef = useRef<Achievement[]>([]);
|
|
|
|
|
|
|
|
|
|
const sheetNowMs = useUiClock(100);
|
|
|
|
|
|
|
|
|
|
const addLogEntry = useCallback((message: string) => {
|
|
|
|
|
logIdCounter.current += 1;
|
|
|
|
|
const entry: AdventureLogEntry = {
|
|
|
|
|
@ -444,10 +473,6 @@ export function App() {
|
|
|
|
|
.then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
|
|
|
|
|
.catch(() => console.warn('[App] Could not fetch achievements'));
|
|
|
|
|
|
|
|
|
|
getDailyTasks(telegramId)
|
|
|
|
|
.then((t) => setDailyTasks(t))
|
|
|
|
|
.catch(() => console.warn('[App] Could not fetch daily tasks'));
|
|
|
|
|
|
|
|
|
|
// Poll nearby heroes every 5 seconds
|
|
|
|
|
const nearbyInterval = setInterval(() => {
|
|
|
|
|
getNearbyHeroes(telegramId)
|
|
|
|
|
@ -471,18 +496,12 @@ export function App() {
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
nearbyIntervalRef.current = nearbyInterval;
|
|
|
|
|
|
|
|
|
|
// Fetch adventure log
|
|
|
|
|
// Fetch adventure log (same source as server DB; response shape { log: [...] })
|
|
|
|
|
try {
|
|
|
|
|
const serverLog = await getAdventureLog(telegramId, 50);
|
|
|
|
|
if (serverLog.length > 0) {
|
|
|
|
|
const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({
|
|
|
|
|
id: i + 1,
|
|
|
|
|
message: entry.message,
|
|
|
|
|
timestamp: new Date(entry.createdAt).getTime(),
|
|
|
|
|
}));
|
|
|
|
|
logIdCounter.current = mapped.length;
|
|
|
|
|
setLogEntries(mapped);
|
|
|
|
|
}
|
|
|
|
|
const { entries, maxId } = adventureEntriesFromServerLog(serverLog);
|
|
|
|
|
logIdCounter.current = Math.max(logIdCounter.current, maxId);
|
|
|
|
|
setLogEntries(entries);
|
|
|
|
|
} catch {
|
|
|
|
|
console.warn('[App] Could not fetch adventure log');
|
|
|
|
|
}
|
|
|
|
|
@ -529,18 +548,17 @@ export function App() {
|
|
|
|
|
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}`);
|
|
|
|
|
if (parts.length > 0) addLogEntry(`Victory: ${parts.join(', ')}`);
|
|
|
|
|
// Victory line comes from server adventure log (Defeated …) + WS adventure_log_line
|
|
|
|
|
|
|
|
|
|
if (p.leveledUp && p.newLevel) {
|
|
|
|
|
setToast({ message: `Level up! Now level ${p.newLevel}`, color: '#ffd700' });
|
|
|
|
|
setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' });
|
|
|
|
|
hapticNotification('success');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Refresh quests, equipment, daily tasks, achievements after combat
|
|
|
|
|
// Refresh quests, equipment, achievements after combat
|
|
|
|
|
const tid = getTelegramUserId() ?? 1;
|
|
|
|
|
getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {});
|
|
|
|
|
refreshEquipment();
|
|
|
|
|
getDailyTasks(tid).then((t) => setDailyTasks(t)).catch(() => {});
|
|
|
|
|
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));
|
|
|
|
|
@ -553,24 +571,24 @@ export function App() {
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onHeroDied: (p) => {
|
|
|
|
|
onHeroDied: (_p) => {
|
|
|
|
|
hapticNotification('error');
|
|
|
|
|
addLogEntry(`Hero was slain by ${p.killedBy}`);
|
|
|
|
|
// Death line comes from server log + WS
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onHeroRevived: () => {
|
|
|
|
|
addLogEntry('Hero revived!');
|
|
|
|
|
setToast({ message: 'Hero revived!', color: '#44cc44' });
|
|
|
|
|
setToast({ message: tr.heroRevived, color: '#44cc44' });
|
|
|
|
|
// "Hero revived" comes from server log + WS
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onBuffApplied: (p) => {
|
|
|
|
|
addLogEntry(`Buff applied: ${p.buffType}`);
|
|
|
|
|
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: `Entering ${p.townName}`, color: '#daa520' });
|
|
|
|
|
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
|
|
|
|
|
addLogEntry(`Entered ${p.townName}`);
|
|
|
|
|
const npcs = p.npcs ?? [];
|
|
|
|
|
if (npcs.length > 0) {
|
|
|
|
|
@ -586,10 +604,13 @@ export function App() {
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onAdventureLogLine: (p) => {
|
|
|
|
|
addLogEntry(p.message);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onTownNPCVisit: (p) => {
|
|
|
|
|
const role =
|
|
|
|
|
p.type === 'merchant' ? 'Shop' : p.type === 'healer' ? 'Healer' : 'Quest';
|
|
|
|
|
addLogEntry(`${role}: ${p.name}`);
|
|
|
|
|
p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel;
|
|
|
|
|
setToast({ message: `${role}: ${p.name}`, color: '#c9a227' });
|
|
|
|
|
setNearestNPC({
|
|
|
|
|
id: p.npcId,
|
|
|
|
|
@ -616,20 +637,27 @@ export function App() {
|
|
|
|
|
setWanderingNPC(npcEvent);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onNPCEncounterEnd: (p) => {
|
|
|
|
|
if (p.reason === 'timeout') {
|
|
|
|
|
addLogEntry('Wandering merchant moved on');
|
|
|
|
|
}
|
|
|
|
|
setWanderingNPC(null);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onLevelUp: (p) => {
|
|
|
|
|
setToast({ message: `Level up! Now level ${p.newLevel}`, color: '#ffd700' });
|
|
|
|
|
setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' });
|
|
|
|
|
hapticNotification('success');
|
|
|
|
|
addLogEntry(`Reached level ${p.newLevel}`);
|
|
|
|
|
// Level-up lines come from server log + WS
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onEquipmentChange: (p) => {
|
|
|
|
|
setToast({ message: `New ${p.slot}: ${p.item.name}`, color: '#cc88ff' });
|
|
|
|
|
addLogEntry(`Equipped ${p.item.name} (${p.slot})`);
|
|
|
|
|
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: `+${p.count} potion${p.count > 1 ? 's' : ''}`, color: '#44cc44' });
|
|
|
|
|
setToast({ message: t(tr.potionsCollected, { count: p.count }), color: '#44cc44' });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onQuestProgress: (p) => {
|
|
|
|
|
@ -642,7 +670,7 @@ export function App() {
|
|
|
|
|
);
|
|
|
|
|
if (p.title) {
|
|
|
|
|
setToast({
|
|
|
|
|
message: `${p.title} (${p.current}/${p.target})`,
|
|
|
|
|
message: t(tr.questProgress, { title: p.title, current: p.current, target: p.target }),
|
|
|
|
|
color: '#44aaff',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
@ -656,13 +684,23 @@ export function App() {
|
|
|
|
|
: hq,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setToast({ message: `Quest completed: ${p.title}!`, color: '#ffd700' });
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.connect();
|
|
|
|
|
@ -706,7 +744,7 @@ export function App() {
|
|
|
|
|
const charge = hero.buffCharges?.[type];
|
|
|
|
|
if (charge != null && charge.remaining <= 0) {
|
|
|
|
|
const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ');
|
|
|
|
|
setToast({ message: `No charges left for ${label}`, color: '#ff8844' });
|
|
|
|
|
setToast({ message: t(tr.noChargesLeft, { label }), color: '#ff8844' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -781,14 +819,14 @@ export function App() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hapticImpact('medium');
|
|
|
|
|
addLogEntry(`Activated ${type} buff`);
|
|
|
|
|
// Server logs buff activation + sends adventure_log_line
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send command to server via WebSocket
|
|
|
|
|
if (ws) {
|
|
|
|
|
sendActivateBuff(ws, type);
|
|
|
|
|
}
|
|
|
|
|
}, [addLogEntry]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleRevive = useCallback(() => {
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
@ -824,13 +862,13 @@ export function App() {
|
|
|
|
|
if (pos) merged.position = pos;
|
|
|
|
|
engine.applyHeroState(merged);
|
|
|
|
|
}
|
|
|
|
|
setToast({ message: 'Quest rewards claimed!', color: '#ffd700' });
|
|
|
|
|
setToast({ message: tr.questRewardsClaimed, color: '#ffd700' });
|
|
|
|
|
hapticNotification('success');
|
|
|
|
|
refreshHeroQuests();
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.warn('[App] Failed to claim quest:', err);
|
|
|
|
|
setToast({ message: 'Failed to claim rewards', color: '#ff4444' });
|
|
|
|
|
setToast({ message: tr.failedToClaimRewards, color: '#ff4444' });
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[refreshHeroQuests],
|
|
|
|
|
@ -841,12 +879,12 @@ export function App() {
|
|
|
|
|
const telegramId = getTelegramUserId() ?? 1;
|
|
|
|
|
abandonQuest(heroQuestId, telegramId)
|
|
|
|
|
.then(() => {
|
|
|
|
|
setToast({ message: 'Quest abandoned', color: '#ff8844' });
|
|
|
|
|
setToast({ message: tr.questAbandoned, color: '#ff8844' });
|
|
|
|
|
refreshHeroQuests();
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.warn('[App] Failed to abandon quest:', err);
|
|
|
|
|
setToast({ message: 'Failed to abandon quest', color: '#ff4444' });
|
|
|
|
|
setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' });
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[refreshHeroQuests],
|
|
|
|
|
@ -902,47 +940,17 @@ export function App() {
|
|
|
|
|
.catch(() => console.warn('[App] Could not fetch hero quests'));
|
|
|
|
|
getAdventureLog(telegramId, 50)
|
|
|
|
|
.then((serverLog) => {
|
|
|
|
|
if (serverLog.length > 0) {
|
|
|
|
|
const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({
|
|
|
|
|
id: i + 1,
|
|
|
|
|
message: entry.message,
|
|
|
|
|
timestamp: new Date(entry.createdAt).getTime(),
|
|
|
|
|
}));
|
|
|
|
|
logIdCounter.current = mapped.length;
|
|
|
|
|
setLogEntries(mapped);
|
|
|
|
|
}
|
|
|
|
|
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(() => {});
|
|
|
|
|
getDailyTasks(telegramId)
|
|
|
|
|
.then((t) => setDailyTasks(t))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleClaimDailyTask = useCallback((taskId: string) => {
|
|
|
|
|
const telegramId = getTelegramUserId() ?? 1;
|
|
|
|
|
claimDailyTask(taskId, 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: 'Daily task reward claimed!', color: '#ffd700' });
|
|
|
|
|
hapticNotification('success');
|
|
|
|
|
getDailyTasks(telegramId).then((t) => setDailyTasks(t)).catch(() => {});
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.warn('[App] Failed to claim daily task:', err);
|
|
|
|
|
setToast({ message: 'Failed to claim reward', color: '#ff4444' });
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleUsePotion = useCallback(() => {
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
const hero = engineRef.current?.gameState.hero;
|
|
|
|
|
@ -954,9 +962,9 @@ export function App() {
|
|
|
|
|
}
|
|
|
|
|
hero.potions--
|
|
|
|
|
|
|
|
|
|
addLogEntry('Used healing potion');
|
|
|
|
|
hapticImpact('medium');
|
|
|
|
|
}, [addLogEntry]);
|
|
|
|
|
// Server logs potion use + sends adventure_log_line
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// ---- NPC Interaction Handlers ----
|
|
|
|
|
|
|
|
|
|
@ -973,49 +981,49 @@ export function App() {
|
|
|
|
|
setNpcInteractionDismissed(npc.id);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleNPCBuyPotion = useCallback((npc: NPCData) => {
|
|
|
|
|
const handleNPCBuyPotion = useCallback((_npc: NPCData) => {
|
|
|
|
|
const telegramId = getTelegramUserId() ?? 1;
|
|
|
|
|
buyPotion(telegramId)
|
|
|
|
|
.then((hero) => {
|
|
|
|
|
hapticImpact('medium');
|
|
|
|
|
setToast({ message: 'Bought a potion for 50 gold', color: '#88dd88' });
|
|
|
|
|
setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' });
|
|
|
|
|
handleNPCHeroUpdated(hero);
|
|
|
|
|
addLogEntry(`Bought potion from ${npc.name}`);
|
|
|
|
|
// 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 ?? 'Failed to buy potion', color: '#ff4444' });
|
|
|
|
|
setToast({ message: j.error ?? tr.failedToBuyPotion, color: '#ff4444' });
|
|
|
|
|
} catch {
|
|
|
|
|
setToast({ message: 'Failed to buy potion', color: '#ff4444' });
|
|
|
|
|
setToast({ message: tr.failedToBuyPotion, color: '#ff4444' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, [handleNPCHeroUpdated, addLogEntry]);
|
|
|
|
|
}, [handleNPCHeroUpdated]);
|
|
|
|
|
|
|
|
|
|
const handleNPCHeal = useCallback((npc: NPCData) => {
|
|
|
|
|
const handleNPCHeal = useCallback((_npc: NPCData) => {
|
|
|
|
|
const telegramId = getTelegramUserId() ?? 1;
|
|
|
|
|
healAtNPC(telegramId)
|
|
|
|
|
.then((hero) => {
|
|
|
|
|
hapticImpact('medium');
|
|
|
|
|
setToast({ message: 'Healed to full HP!', color: '#44cc44' });
|
|
|
|
|
setToast({ message: tr.healedToFull, color: '#44cc44' });
|
|
|
|
|
handleNPCHeroUpdated(hero);
|
|
|
|
|
addLogEntry(`Healed at ${npc.name}`);
|
|
|
|
|
// 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 ?? 'Failed to heal', color: '#ff4444' });
|
|
|
|
|
setToast({ message: j.error ?? tr.failedToHeal, color: '#ff4444' });
|
|
|
|
|
} catch {
|
|
|
|
|
setToast({ message: 'Failed to heal', color: '#ff4444' });
|
|
|
|
|
setToast({ message: tr.failedToHeal, color: '#ff4444' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, [handleNPCHeroUpdated, addLogEntry]);
|
|
|
|
|
}, [handleNPCHeroUpdated]);
|
|
|
|
|
|
|
|
|
|
const handleNPCInteractionDismiss = useCallback(() => {
|
|
|
|
|
if (nearestNPC) {
|
|
|
|
|
@ -1031,8 +1039,8 @@ export function App() {
|
|
|
|
|
sendNPCAlmsAccept(ws);
|
|
|
|
|
}
|
|
|
|
|
setWanderingNPC(null);
|
|
|
|
|
addLogEntry('Accepted wandering merchant offer');
|
|
|
|
|
}, [addLogEntry]);
|
|
|
|
|
// Alms outcome is logged on server + WS when trade completes
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleWanderingDecline = useCallback(() => {
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
@ -1051,6 +1059,7 @@ export function App() {
|
|
|
|
|
!selectedNPC;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
|
|
|
|
|
<div style={appStyle}>
|
|
|
|
|
{/* PixiJS Canvas */}
|
|
|
|
|
<div ref={canvasRef} style={canvasContainerStyle} />
|
|
|
|
|
@ -1062,8 +1071,27 @@ export function App() {
|
|
|
|
|
buffCooldownEndsAt={buffCooldownEndsAt}
|
|
|
|
|
onUsePotion={handleUsePotion}
|
|
|
|
|
onHeroUpdated={handleNPCHeroUpdated}
|
|
|
|
|
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} />
|
|
|
|
|
|
|
|
|
|
@ -1114,70 +1142,6 @@ export function App() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Quest Log Toggle Button */}
|
|
|
|
|
{gameState.hero && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setQuestLogOpen((v) => !v)}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: 80,
|
|
|
|
|
right: 16,
|
|
|
|
|
width: 44,
|
|
|
|
|
height: 44,
|
|
|
|
|
borderRadius: 22,
|
|
|
|
|
border: '2px solid rgba(255, 215, 0, 0.35)',
|
|
|
|
|
backgroundColor: 'rgba(15, 15, 25, 0.85)',
|
|
|
|
|
color: '#ffd700',
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
zIndex: 50,
|
|
|
|
|
boxShadow: '0 2px 10px rgba(0,0,0,0.4)',
|
|
|
|
|
pointerEvents: 'auto',
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Quest Log"
|
|
|
|
|
>
|
|
|
|
|
{'\uD83D\uDCDC'}
|
|
|
|
|
{heroQuests.filter((q) => q.status !== 'claimed').length > 0 && (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: -4,
|
|
|
|
|
right: -4,
|
|
|
|
|
minWidth: 18,
|
|
|
|
|
height: 18,
|
|
|
|
|
borderRadius: 9,
|
|
|
|
|
backgroundColor: heroQuests.some((q) => q.status === 'completed')
|
|
|
|
|
? '#ffd700'
|
|
|
|
|
: '#4a90d9',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
padding: '0 4px',
|
|
|
|
|
border: '1px solid rgba(0,0,0,0.3)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{heroQuests.filter((q) => q.status !== 'claimed').length}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Quest Log Panel */}
|
|
|
|
|
{questLogOpen && (
|
|
|
|
|
<QuestLog
|
|
|
|
|
quests={heroQuests}
|
|
|
|
|
onClaim={handleQuestClaim}
|
|
|
|
|
onAbandon={handleQuestAbandon}
|
|
|
|
|
onClose={() => setQuestLogOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* NPC Proximity Interaction */}
|
|
|
|
|
{showNPCInteraction && nearestNPC && (
|
|
|
|
|
<NPCInteraction
|
|
|
|
|
@ -1215,10 +1179,7 @@ export function App() {
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Daily Tasks (top-right) */}
|
|
|
|
|
{gameState.hero && <DailyTasks tasks={dailyTasks} onClaim={handleClaimDailyTask} />}
|
|
|
|
|
|
|
|
|
|
{/* Minimap (below daily tasks, top-right) */}
|
|
|
|
|
{/* Minimap (top-right) */}
|
|
|
|
|
{gameState.hero && (
|
|
|
|
|
<Minimap
|
|
|
|
|
heroX={gameState.hero.position.x}
|
|
|
|
|
@ -1231,9 +1192,6 @@ export function App() {
|
|
|
|
|
{/* Achievements Panel */}
|
|
|
|
|
{gameState.hero && <AchievementsPanel achievements={achievements} />}
|
|
|
|
|
|
|
|
|
|
{/* Adventure Log */}
|
|
|
|
|
<AdventureLog entries={logEntries} />
|
|
|
|
|
|
|
|
|
|
{/* Offline Report Overlay */}
|
|
|
|
|
{offlineReport && (
|
|
|
|
|
<OfflineReport
|
|
|
|
|
@ -1276,5 +1234,6 @@ export function App() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</I18nContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|