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
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;
|
|
}
|