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.

310 lines
8.9 KiB
TypeScript

import type { GameWebSocket, ServerMessage } from '../network/websocket';
import type { GameEngine } from './engine';
import type {
HeroMovePayload,
PositionSyncPayload,
RouteAssignedPayload,
CombatStartPayload,
AttackPayload,
CombatEndPayload,
HeroDiedPayload,
HeroRevivedPayload,
BuffAppliedPayload,
TownEnterPayload,
TownNPCVisitPayload,
AdventureLogLinePayload,
NPCEncounterPayload,
NPCEncounterEndPayload,
LevelUpPayload,
EquipmentChangePayload,
PotionCollectedPayload,
QuestProgressPayload,
QuestCompletePayload,
QuestAvailablePayload,
ServerErrorPayload,
EnemyState,
LootDrop,
MerchantLootPayload,
} from './types';
import { EnemyType, Rarity } from './types';
// ---- Callback types for UI layer (App.tsx) ----
export interface WSHandlerCallbacks {
onCombatEnd?: (payload: CombatEndPayload) => void;
onHeroDied?: (payload: HeroDiedPayload) => void;
onHeroRevived?: (payload: HeroRevivedPayload) => void;
onBuffApplied?: (payload: BuffAppliedPayload) => void;
onTownEnter?: (payload: TownEnterPayload) => void;
onTownNPCVisit?: (payload: TownNPCVisitPayload) => void;
onAdventureLogLine?: (payload: AdventureLogLinePayload) => void;
onTownExit?: () => void;
onNPCEncounter?: (payload: NPCEncounterPayload) => void;
onNPCEncounterEnd?: (payload: NPCEncounterEndPayload) => void;
onLevelUp?: (payload: LevelUpPayload) => void;
onEquipmentChange?: (payload: EquipmentChangePayload) => void;
onPotionCollected?: (payload: PotionCollectedPayload) => void;
onQuestProgress?: (payload: QuestProgressPayload) => void;
onQuestComplete?: (payload: QuestCompletePayload) => void;
onQuestAvailable?: (payload: QuestAvailablePayload) => void;
onError?: (payload: ServerErrorPayload) => void;
onHeroStateReceived?: (hero: Record<string, unknown>) => void;
onMerchantLoot?: (payload: MerchantLootPayload) => void;
}
/**
* Bridges WebSocket messages to engine state updates and UI callbacks.
*
* This is a stateless dispatcher: it parses typed envelopes from the
* server and calls the appropriate engine method or UI callback.
* No game logic lives here -- just routing.
*/
export function wireWSHandler(
ws: GameWebSocket,
engine: GameEngine,
callbacks: WSHandlerCallbacks,
): void {
// ---- Server -> Client: Movement ----
ws.on('hero_move', (msg: ServerMessage) => {
const p = msg.payload as HeroMovePayload;
engine.applyHeroMove(p.x, p.y, p.targetX, p.targetY, p.speed);
});
ws.on('position_sync', (msg: ServerMessage) => {
const p = msg.payload as PositionSyncPayload;
engine.applyPositionSync(p.x, p.y, p.state);
});
ws.on('route_assigned', (msg: ServerMessage) => {
const p = msg.payload as RouteAssignedPayload;
engine.applyRouteAssigned(p.waypoints, p.speed);
});
// ---- Server -> Client: Hero state snapshot ----
ws.on('hero_state', (msg: ServerMessage) => {
const payload = msg.payload as Record<string, unknown>;
callbacks.onHeroStateReceived?.(payload);
});
// ---- Server -> Client: Combat ----
ws.on('combat_start', (msg: ServerMessage) => {
const p = msg.payload as CombatStartPayload;
const enemy: EnemyState = {
id: Date.now(),
name: p.enemy.name,
hp: p.enemy.hp,
maxHp: p.enemy.maxHp,
position: { x: 0, y: 0 }, // engine will position relative to hero
attackSpeed: p.enemy.speed,
damage: p.enemy.attack,
defense: p.enemy.defense,
enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf,
};
engine.applyCombatStart(enemy);
});
ws.on('attack', (msg: ServerMessage) => {
const p = msg.payload as AttackPayload;
engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp);
});
ws.on('combat_end', (msg: ServerMessage) => {
const p = msg.payload as CombatEndPayload;
engine.applyCombatEnd();
callbacks.onCombatEnd?.(p);
});
ws.on('merchant_loot', (msg: ServerMessage) => {
const p = msg.payload as MerchantLootPayload;
callbacks.onMerchantLoot?.(p);
});
// ---- Server -> Client: Death / Revive ----
ws.on('hero_died', (msg: ServerMessage) => {
const p = msg.payload as HeroDiedPayload;
engine.applyHeroDied();
callbacks.onHeroDied?.(p);
});
ws.on('hero_revived', (msg: ServerMessage) => {
const p = msg.payload as HeroRevivedPayload;
engine.applyHeroRevived(p.hp);
callbacks.onHeroRevived?.(p);
});
// ---- Server -> Client: Buffs ----
ws.on('buff_applied', (msg: ServerMessage) => {
const p = msg.payload as BuffAppliedPayload;
callbacks.onBuffApplied?.(p);
});
// ---- Server -> Client: Town ----
ws.on('town_enter', (msg: ServerMessage) => {
const p = msg.payload as TownEnterPayload;
engine.applyTownEnter(p.townId, p.buildings as any);
callbacks.onTownEnter?.(p);
});
ws.on('town_exit', () => {
engine.applyTownExit();
callbacks.onTownExit?.();
});
ws.on('town_npc_visit', (msg: ServerMessage) => {
const p = msg.payload as TownNPCVisitPayload;
engine.applyTownNPCVisit(p.name, p.type);
callbacks.onTownNPCVisit?.(p);
});
ws.on('adventure_log_line', (msg: ServerMessage) => {
const p = msg.payload as AdventureLogLinePayload;
engine.applyAdventureLogLine(p.message);
callbacks.onAdventureLogLine?.(p);
});
// ---- Server -> Client: NPC Encounter ----
ws.on('npc_encounter', (msg: ServerMessage) => {
const p = msg.payload as NPCEncounterPayload;
callbacks.onNPCEncounter?.(p);
});
ws.on('npc_encounter_end', (msg: ServerMessage) => {
const p = msg.payload as NPCEncounterEndPayload;
callbacks.onNPCEncounterEnd?.(p);
});
// ---- Server -> Client: Progression ----
ws.on('level_up', (msg: ServerMessage) => {
const p = msg.payload as LevelUpPayload;
callbacks.onLevelUp?.(p);
});
ws.on('equipment_change', (msg: ServerMessage) => {
const p = msg.payload as EquipmentChangePayload;
callbacks.onEquipmentChange?.(p);
});
ws.on('potion_collected', (msg: ServerMessage) => {
const p = msg.payload as PotionCollectedPayload;
callbacks.onPotionCollected?.(p);
});
// ---- Server -> Client: Quests ----
ws.on('quest_progress', (msg: ServerMessage) => {
const p = msg.payload as QuestProgressPayload;
callbacks.onQuestProgress?.(p);
});
ws.on('quest_complete', (msg: ServerMessage) => {
const p = msg.payload as QuestCompletePayload;
callbacks.onQuestComplete?.(p);
});
ws.on('quest_available', (msg: ServerMessage) => {
const p = msg.payload as QuestAvailablePayload;
callbacks.onQuestAvailable?.(p);
});
// ---- Server -> Client: Error ----
ws.on('error', (msg: ServerMessage) => {
const p = msg.payload as ServerErrorPayload;
console.warn('[WS] Server error:', p.code, p.message);
callbacks.onError?.(p);
});
}
// ---- Client -> Server command helpers ----
export function sendActivateBuff(ws: GameWebSocket, buffType: string): void {
ws.send('activate_buff', { buffType });
}
export function sendUsePotion(ws: GameWebSocket): void {
ws.send('use_potion', {});
}
export function sendRevive(ws: GameWebSocket): void {
ws.send('revive', {});
}
export function sendAcceptQuest(ws: GameWebSocket, questId: number): void {
ws.send('accept_quest', { questId });
}
export function sendClaimQuest(ws: GameWebSocket, questId: number): void {
ws.send('claim_quest', { questId });
}
export function sendNPCInteract(ws: GameWebSocket, npcId: number): void {
ws.send('npc_interact', { npcId });
}
export function sendNPCAlmsAccept(ws: GameWebSocket): void {
ws.send('npc_alms_accept', {});
}
export function sendNPCAlmsDecline(ws: GameWebSocket): void {
ws.send('npc_alms_decline', {});
}
/**
* Build a LootDrop from combat_end payload for the loot popup UI.
*/
export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null {
if (p.goldGained <= 0 && p.loot.length === 0) return null;
const equip = p.loot.find(
(l) => l.itemType === 'weapon' || l.itemType === 'armor',
);
return {
itemType: 'gold',
rarity: equip
? ((equip.rarity?.toLowerCase() ?? 'common') as Rarity)
: Rarity.Common,
goldAmount: Math.max(0, p.goldGained),
itemName: equip?.name,
bonusItem: equip
? {
itemType: equip.itemType as 'weapon' | 'armor',
rarity: (equip.rarity?.toLowerCase() ?? 'common') as Rarity,
itemName: equip.name,
}
: undefined,
};
}
/**
* Build a LootDrop for the popup after a wandering merchant trade (gear only; equip or auto-sell).
*/
export function buildMerchantLootDrop(p: MerchantLootPayload): LootDrop | null {
const rarity = (p.rarity?.toLowerCase() ?? 'common') as Rarity;
if (p.goldAmount != null && p.goldAmount > 0) {
return {
itemType: 'gold',
rarity,
goldAmount: p.goldAmount,
itemName: p.itemName ? `Sold: ${p.itemName}` : undefined,
};
}
if (p.itemName) {
return {
itemType: 'gold',
rarity,
goldAmount: 0,
itemName: p.itemName,
};
}
return null;
}