From 517334d18d8025b1d933a246a8f2e49828e2e789 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Sat, 4 Apr 2026 04:36:21 +0300 Subject: [PATCH] hero movements --- backend/internal/game/engine.go | 77 ++++++++++++++++++ backend/internal/handler/ws.go | 98 ++++++++++++++++++++++- backend/internal/model/ws_message.go | 32 ++++++++ frontend/public/assets/game/manifest.json | 38 +++++---- frontend/src/App.tsx | 51 ++++++------ frontend/src/game/engine.ts | 9 +++ frontend/src/game/types.ts | 16 ++++ frontend/src/game/ws-handler.ts | 28 +++++++ 8 files changed, 307 insertions(+), 42 deletions(-) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 3184f81..38f41f2 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -21,6 +21,11 @@ type MessageSender interface { BroadcastEvent(event model.CombatEvent) } +// NearbySubscriptionManager can attach nearby-hero movement subscriptions for a viewer. +type NearbySubscriptionManager interface { + SetNearbySubscriptions(viewerID int64, targetIDs []int64) +} + // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS. type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop @@ -434,6 +439,8 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) { return } switch msg.Type { + case "request_nearby_heroes": + e.handleNearbyHeroesRequest(msg) case "activate_buff": e.handleActivateBuff(msg) case "use_potion": @@ -461,6 +468,76 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) { } } +func (e *Engine) handleNearbyHeroesRequest(msg IncomingMessage) { + if e.heroStore == nil || e.sender == nil { + return + } + var req model.NearbyHeroesRequestPayload + if len(msg.Payload) > 0 { + if err := json.Unmarshal(msg.Payload, &req); err != nil { + e.sendError(msg.HeroID, "invalid_payload", "invalid request_nearby_heroes payload") + return + } + } + radius := 50.0 + if req.Radius > 0 { + radius = req.Radius + } + if radius > 100 { + radius = 100 + } + limit := 5 + if req.Limit > 0 { + limit = req.Limit + } + if limit > 5 { + limit = 5 + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + hero, err := e.heroStore.GetByID(ctx, msg.HeroID) + if err != nil || hero == nil { + e.sendError(msg.HeroID, "no_hero", "hero not found") + return + } + + posX, posY := hero.PositionX, hero.PositionY + if wx, wy, ok := e.HeroWorldPositionForCombat(hero.ID); ok { + posX, posY = wx, wy + } + + nearby, err := e.heroStore.GetNearbyHeroes(ctx, hero.ID, posX, posY, radius, limit) + if err != nil { + e.logger.Error("failed to load nearby heroes", "hero_id", hero.ID, "error", err) + e.sendError(msg.HeroID, "nearby_load_failed", "failed to load nearby heroes") + return + } + e.OverlayResidentWorldPositionsOnNearby(nearby) + + summaries := make([]model.NearbyHeroSummary, 0, len(nearby)) + ids := make([]int64, 0, len(nearby)) + for _, h := range nearby { + summaries = append(summaries, model.NearbyHeroSummary{ + ID: h.ID, + Name: h.Name, + Level: h.Level, + ModelVariant: h.ModelVariant, + PositionX: h.PositionX, + PositionY: h.PositionY, + }) + ids = append(ids, h.ID) + } + + e.sender.SendToHero(msg.HeroID, "nearby_heroes", model.NearbyHeroesPayload{ + Heroes: summaries, + }) + + if sub, ok := e.sender.(NearbySubscriptionManager); ok { + sub.SetNearbySubscriptions(msg.HeroID, ids) + } +} + // handleActivateBuff processes the activate_buff client command. func (e *Engine) handleActivateBuff(msg IncomingMessage) { var payload model.ActivateBuffPayload diff --git a/backend/internal/handler/ws.go b/backend/internal/handler/ws.go index 0730856..edea4c4 100644 --- a/backend/internal/handler/ws.go +++ b/backend/internal/handler/ws.go @@ -34,6 +34,11 @@ type Hub struct { mu sync.RWMutex logger *slog.Logger + // nearbySubscriptions maps target hero -> viewers subscribed to its movement updates. + nearbySubscriptions map[int64]map[int64]struct{} + // viewerSubscriptions maps viewer hero -> targets they are currently subscribed to. + viewerSubscriptions map[int64]map[int64]struct{} + // OnConnect is called when a client finishes registration. // Set by the engine to push initial state. May be nil. OnConnect func(heroID int64) @@ -69,6 +74,8 @@ func NewHub(logger *slog.Logger) *Hub { broadcast: make(chan model.WSEnvelope, 256), Incoming: make(chan model.ClientMessage, 256), logger: logger, + nearbySubscriptions: make(map[int64]map[int64]struct{}), + viewerSubscriptions: make(map[int64]map[int64]struct{}), } } @@ -102,6 +109,9 @@ func (h *Hub) Run() { remaining++ } } + if remaining == 0 { + h.removeNearbySubscriptionsLocked(heroID) + } h.mu.Unlock() h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining) @@ -144,8 +154,36 @@ func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { } } env := model.NewWSEnvelope(msgType, payload) + + var nearbyEnv *model.WSEnvelope + var nearbySubs map[int64]struct{} + if msgType == "hero_move" { + var move model.HeroMovePayload + switch v := payload.(type) { + case model.HeroMovePayload: + move = v + case *model.HeroMovePayload: + if v != nil { + move = *v + } + default: + move = model.HeroMovePayload{} + } + nearbyPayload := model.NearbyHeroMovePayload{ + HeroID: heroID, + X: move.X, + Y: move.Y, + TargetX: move.TargetX, + TargetY: move.TargetY, + Speed: move.Speed, + Heading: move.Heading, + } + envBuilt := model.NewWSEnvelope("nearby_hero_move", nearbyPayload) + nearbyEnv = &envBuilt + } + h.mu.RLock() - defer h.mu.RUnlock() + nearbySubs = h.nearbySubscriptions[heroID] for client := range h.clients { if client.heroID == heroID { select { @@ -156,8 +194,66 @@ func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { h.unregister <- c }(client) } + continue + } + if nearbyEnv != nil && nearbySubs != nil { + if _, ok := nearbySubs[client.heroID]; ok { + select { + case client.send <- *nearbyEnv: + default: + go func(c *Client) { + h.unregister <- c + }(client) + } + } + } + } + h.mu.RUnlock() +} + +// SetNearbySubscriptions replaces the viewer's subscriptions with the provided target hero IDs. +func (h *Hub) SetNearbySubscriptions(viewerID int64, targets []int64) { + h.mu.Lock() + defer h.mu.Unlock() + h.removeNearbySubscriptionsLocked(viewerID) + if len(targets) == 0 { + return + } + next := make(map[int64]struct{}, len(targets)) + for _, targetID := range targets { + if targetID == viewerID || targetID <= 0 { + continue + } + next[targetID] = struct{}{} + subs := h.nearbySubscriptions[targetID] + if subs == nil { + subs = make(map[int64]struct{}) + h.nearbySubscriptions[targetID] = subs + } + subs[viewerID] = struct{}{} + } + if len(next) > 0 { + h.viewerSubscriptions[viewerID] = next + } +} + +func (h *Hub) removeNearbySubscriptionsLocked(viewerID int64) { + existing := h.viewerSubscriptions[viewerID] + if len(existing) == 0 { + delete(h.viewerSubscriptions, viewerID) + return + } + for targetID := range existing { + subs := h.nearbySubscriptions[targetID] + if subs == nil { + continue + } + delete(subs, viewerID) + if len(subs) == 0 { + delete(h.nearbySubscriptions, targetID) } } + delete(h.viewerSubscriptions, viewerID) } // BroadcastAll sends an envelope to every connected client (rare: server announcements). diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index c4e8d50..4c7d8a1 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -243,6 +243,32 @@ type DebuffAppliedPayload struct { ExpiresAt time.Time `json:"expiresAt"` } +// NearbyHeroSummary is a lightweight snapshot for shared-world rendering updates. +type NearbyHeroSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + Level int `json:"level"` + ModelVariant int `json:"modelVariant"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` +} + +// NearbyHeroesPayload returns the nearby hero list (server -> client). +type NearbyHeroesPayload struct { + Heroes []NearbyHeroSummary `json:"heroes"` +} + +// NearbyHeroMovePayload pushes live movement for a nearby hero (server -> client). +type NearbyHeroMovePayload struct { + HeroID int64 `json:"heroId"` + X float64 `json:"x"` + Y float64 `json:"y"` + TargetX float64 `json:"targetX"` + TargetY float64 `json:"targetY"` + Speed float64 `json:"speed"` + Heading float64 `json:"heading,omitempty"` +} + // ErrorPayload is sent when a client command fails validation. type ErrorPayload struct { Code string `json:"code"` @@ -251,6 +277,12 @@ type ErrorPayload struct { // --- Client -> Server payload types --- +// NearbyHeroesRequestPayload is the payload for the request_nearby_heroes command. +type NearbyHeroesRequestPayload struct { + Radius float64 `json:"radius,omitempty"` + Limit int `json:"limit,omitempty"` +} + // ActivateBuffPayload is the payload for the activate_buff command. type ActivateBuffPayload struct { BuffType string `json:"buffType"` diff --git a/frontend/public/assets/game/manifest.json b/frontend/public/assets/game/manifest.json index e5b9075..b75679a 100644 --- a/frontend/public/assets/game/manifest.json +++ b/frontend/public/assets/game/manifest.json @@ -1,5 +1,5 @@ { - "version": 32, + "version": 34, "assetsRoot": "frontend/assets", "note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.", "textures": { @@ -1158,47 +1158,49 @@ "file": "enemies/enemy.forest_warden_l25_26_ruins.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "e50f50b1-b43e-4cc4-9251-7181b044850c" + "pixellabObjectId": "f00c5fb3-e16b-4df4-b60a-62c5a464ab7d" }, "enemy.forest_warden_l25_26_canyon.south": { "file": "enemies/enemy.forest_warden_l25_26_canyon.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "416e283b-b09f-4970-81d6-ca3fb85decc6" + "pixellabObjectId": "a0050901-d512-4d68-a984-c4e935a8c2f7" }, "enemy.forest_warden_l27_28_canyon.south": { "file": "enemies/enemy.forest_warden_l27_28_canyon.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "17ee0740-2c27-404d-ba56-e66ecd76acfb" + "pixellabObjectId": "160a533b-5682-4783-b91e-d35989957264" }, "enemy.forest_warden_l27_28_swamp.south": { "file": "enemies/enemy.forest_warden_l27_28_swamp.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "de1c3fef-2f91-44c0-b080-f4750f48e6f6" + "pixellabObjectId": "f48351ec-ca10-4d81-9f75-fd02eb3c9747" }, "enemy.forest_warden_l29_30_volcanic.south": { "file": "enemies/enemy.forest_warden_l29_30_volcanic.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "453a1ba7-434e-42ac-89de-5d93e604cad8" + "pixellabObjectId": "15a1a8de-e7ee-49f9-807f-f2ba5432a889" }, "enemy.forest_warden_l29_30_astral.south": { "file": "enemies/enemy.forest_warden_l29_30_astral.south.png", "kind": "map_object", "rotation": "south", - "pixellabObjectId": "9fa1b822-692a-41d6-a8f1-a9f9bc58227f" + "pixellabObjectId": "ee307818-3ecc-4409-a954-8eaa2f2c8314" }, "enemy.titan_l25_27_meadow.south": { "file": "enemies/enemy.titan_l25_27_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "c580f2e2-28e5-42de-8ba1-b4e5a69db552" }, "enemy.titan_l25_27_forest.south": { "file": "enemies/enemy.titan_l25_27_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "c83671b8-6e84-4875-aab2-e0496ec40816" }, "enemy.titan_l28_29_forest.south": { "file": "enemies/enemy.titan_l28_29_forest.south.png", @@ -1633,12 +1635,14 @@ "enemy.harpy_l6_7_meadow.south": { "file": "enemies/enemy.harpy_l6_7_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "f4266917-7b96-4521-ba3b-fccb7100fe22" }, "enemy.harpy_l6_7_forest.south": { "file": "enemies/enemy.harpy_l6_7_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "38b353a7-dd0b-4c4b-849f-1fae18475dff" }, "enemy.harpy_l8_9_forest.south": { "file": "enemies/enemy.harpy_l8_9_forest.south.png", @@ -1665,22 +1669,26 @@ "enemy.harpy_l12_13_canyon.south": { "file": "enemies/enemy.harpy_l12_13_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "331c7c06-8860-455f-a8df-67f65d4c0b84" }, "enemy.harpy_l12_13_swamp.south": { "file": "enemies/enemy.harpy_l12_13_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "bcd63d31-c191-4aba-95ce-f7e24b9c8129" }, "enemy.harpy_l14_15_volcanic.south": { "file": "enemies/enemy.harpy_l14_15_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "d9b99150-0bd0-47ac-bfdf-0210099d2751" }, "enemy.harpy_l14_15_astral.south": { "file": "enemies/enemy.harpy_l14_15_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "043ac4f8-16cf-420d-8d2b-a72e50d42a72" }, "enemy.manticore_l14_16_meadow.south": { "file": "enemies/enemy.manticore_l14_16_meadow.south.png", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49c3fb8..ea7a416 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { sendTownTourNPCDialogClosed, sendTownTourNPCInteractionOpened, sendTownTourNPCInteractionClosed, + sendRequestNearbyHeroes, buildLootFromCombatEnd, buildMerchantLootDrop, } from './game/ws-handler'; @@ -37,7 +38,6 @@ import { claimQuest, abandonQuest, getAchievements, - getNearbyHeroes, requestRevive, defaultNpcShopBundle, npcShopCostsFromInit, @@ -825,31 +825,6 @@ export function App() { .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => console.warn('[App] Could not fetch achievements')); - // Poll nearby heroes every 5 seconds - const nearbyInterval = setInterval(() => { - getNearbyHeroes(telegramId) - .then((heroes) => engine.setNearbyHeroes(heroes.map((h) => ({ - id: h.id, - name: h.name, - level: h.level, - modelVariant: normalizeHeroModelVariant(h.modelVariant), - positionX: h.positionX, - positionY: h.positionY, - })))) - .catch(() => {}); - }, 5000); - getNearbyHeroes(telegramId) - .then((heroes) => engine.setNearbyHeroes(heroes.map((h) => ({ - id: h.id, - name: h.name, - level: h.level, - modelVariant: normalizeHeroModelVariant(h.modelVariant), - positionX: h.positionX, - positionY: h.positionY, - })))) - .catch(() => {}); - nearbyIntervalRef.current = nearbyInterval; - // Fetch adventure log (same source as server DB; response shape { log: [...] }) try { const serverLog = await getAdventureLog(telegramId, 50); @@ -1144,6 +1119,30 @@ export function App() { }; }, []); + // 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; diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 1b8c8c1..5f7070c 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -192,6 +192,15 @@ export class GameEngine { this._nearbyHeroes = heroes; } + /** Apply a live movement update for a nearby hero (WS subscription). */ + applyNearbyHeroMove(heroId: number, x: number, y: number): void { + const idx = this._nearbyHeroes.findIndex((h) => h.id === heroId); + if (idx < 0) return; + const hero = this._nearbyHeroes[idx]!; + if (hero.positionX === x && hero.positionY === y) return; + this._nearbyHeroes[idx] = { ...hero, positionX: x, positionY: y }; + } + /** Start hero meet UI overlay (partner stands near you). */ setHeroMeetOverlay(partner: NearbyHeroData): void { this._heroMeetOverlay = { ...partner }; diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 0587cf3..a10d148 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -480,6 +480,8 @@ export type ServerMessageType = | 'hero_died' | 'hero_revived' | 'buff_applied' + | 'nearby_heroes' + | 'nearby_hero_move' | 'town_enter' | 'town_exit' | 'town_npc_visit' @@ -540,6 +542,20 @@ export interface CombatStartPayload { }; } +export interface NearbyHeroesPayload { + heroes: NearbyHeroData[]; +} + +export interface NearbyHeroMovePayload { + heroId: number; + x: number; + y: number; + targetX: number; + targetY: number; + speed: number; + heading?: number; +} + export interface AttackPayload { source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon'; damage: number; diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index 0ac9568..b62a6eb 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -33,6 +33,8 @@ import type { HeroMeetStartPayload, HeroMeetLinePayload, HeroMeetEndPayload, + NearbyHeroesPayload, + NearbyHeroMovePayload, } from './types'; import { DebuffType, Rarity } from './types'; // ---- Callback types for UI layer (App.tsx) ---- @@ -191,6 +193,24 @@ export function wireWSHandler( callbacks.onBuffApplied?.(p); }); + ws.on('nearby_heroes', (msg: ServerMessage) => { + const p = msg.payload as NearbyHeroesPayload; + const heroes = Array.isArray(p?.heroes) ? p.heroes : []; + const sanitized = heroes.map((h) => ({ + ...h, + modelVariant: Number.isFinite(h.modelVariant) ? h.modelVariant : 0, + positionX: Number.isFinite(h.positionX) ? h.positionX : 0, + positionY: Number.isFinite(h.positionY) ? h.positionY : 0, + })); + engine.setNearbyHeroes(sanitized); + }); + + ws.on('nearby_hero_move', (msg: ServerMessage) => { + const p = msg.payload as NearbyHeroMovePayload; + if (!p || !Number.isFinite(p.heroId)) return; + engine.applyNearbyHeroMove(p.heroId, p.x, p.y); + }); + // ---- Server -> Client: Town ---- ws.on('town_enter', (msg: ServerMessage) => { @@ -351,6 +371,14 @@ export function sendHeroMeetEndConversation(ws: GameWebSocket): void { ws.send('hero_meet_end_conversation', {}); } +export function sendRequestNearbyHeroes(ws: GameWebSocket, radius?: number): void { + if (radius && Number.isFinite(radius)) { + ws.send('request_nearby_heroes', { radius }); + return; + } + ws.send('request_nearby_heroes', {}); +} + /** * Build a LootDrop from combat_end payload for the loot popup UI. */