hero movements

master
Denis Ranneft 1 month ago
parent 1ad235a68b
commit 517334d18d

@ -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

@ -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).

@ -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"`

@ -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",

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

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

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

@ -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.
*/

Loading…
Cancel
Save