some fixes for heroes and local testing

master
Denis Ranneft 11 hours ago
parent 867a00bf8c
commit 1fdfdbfcda

@ -89,8 +89,8 @@ func main() {
} }
engine.RegisterHeroMovement(hero) engine.RegisterHeroMovement(hero)
} }
hub.OnDisconnect = func(heroID int64) { hub.OnDisconnect = func(heroID int64, remainingSameHero int) {
engine.UnregisterHeroMovement(heroID) engine.HeroSocketDetached(heroID, remainingSameHero == 0)
} }
// Bridge hub incoming client messages to engine's command channel. // Bridge hub incoming client messages to engine's command channel.

@ -329,6 +329,31 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
} }
now := time.Now() now := time.Now()
// Reconnect while the previous socket is still tearing down: keep live movement so we
// do not replace (x,y) and route with a stale DB snapshot.
if existing, ok := e.movements[hero.ID]; ok {
existing.Hero.RefreshDerivedCombatStats(now)
e.logger.Info("hero movement reattached (existing session)",
"hero_id", hero.ID,
"state", existing.State,
"pos_x", existing.CurrentX,
"pos_y", existing.CurrentY,
)
if e.sender != nil {
e.sender.SendToHero(hero.ID, "hero_state", existing.Hero)
if route := existing.RoutePayload(); route != nil {
e.sender.SendToHero(hero.ID, "route_assigned", route)
}
if cs, ok := e.combats[hero.ID]; ok {
e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{
Enemy: enemyToInfo(&cs.Enemy),
})
}
}
return
}
hm := NewHeroMovement(hero, e.roadGraph, now) hm := NewHeroMovement(hero, e.roadGraph, now)
e.movements[hero.ID] = hm e.movements[hero.ID] = hm
@ -357,24 +382,33 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
} }
} }
// UnregisterHeroMovement removes movement state and persists hero to DB. // HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
// Called when a WS client disconnects. // movement only when lastConnection is true (no other tabs/sockets for this hero).
func (e *Engine) UnregisterHeroMovement(heroID int64) { func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
e.mu.Lock() e.mu.Lock()
hm, ok := e.movements[heroID] hm, ok := e.movements[heroID]
if ok { if ok {
hm.SyncToHero() hm.SyncToHero()
delete(e.movements, heroID) if lastConnection {
delete(e.movements, heroID)
}
}
var heroSnap *model.Hero
if ok {
heroSnap = hm.Hero
} }
e.mu.Unlock() e.mu.Unlock()
if ok && e.heroStore != nil { if ok && e.heroStore != nil && heroSnap != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := e.heroStore.Save(ctx, hm.Hero); err != nil { if err := e.heroStore.Save(ctx, heroSnap); err != nil {
e.logger.Error("failed to save hero on disconnect", "hero_id", heroID, "error", err) e.logger.Error("failed to save hero on ws disconnect", "hero_id", heroID, "error", err)
} else { } else {
e.logger.Info("hero state persisted on disconnect", "hero_id", heroID) e.logger.Info("hero state persisted on ws disconnect",
"hero_id", heroID,
"last_connection", lastConnection,
)
} }
} }
} }

@ -89,15 +89,24 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
dir = -1 dir = -1
} }
// Add per-hero position offset so heroes on the same road don't overlap. // Persisted (x,y) already include any in-world offset from prior sessions; do not add
// Use hero ID to create a stable lateral offset of ±1.5 tiles. // lateral jitter again on reconnect (that doubled the shift every reload). Only spread
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 // new heroes that still sit at the default origin.
var curX, curY float64
if hero.PositionX == 0 && hero.PositionY == 0 {
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5
curX = lateralOffset * 0.3
curY = lateralOffset * 0.7
} else {
curX = hero.PositionX
curY = hero.PositionY
}
hm := &HeroMovement{ hm := &HeroMovement{
HeroID: hero.ID, HeroID: hero.ID,
Hero: hero, Hero: hero,
CurrentX: hero.PositionX + lateralOffset*0.3, CurrentX: curX,
CurrentY: hero.PositionY + lateralOffset*0.7, CurrentY: curY,
State: hero.State, State: hero.State,
LastMoveTick: now, LastMoveTick: now,
Direction: dir, Direction: dir,
@ -278,18 +287,56 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
} }
hm.Road = jitteredRoad hm.Road = jitteredRoad
hm.WaypointIndex = 0 // Restore progress along this hero's jittered polyline from saved world position.
hm.WaypointFraction = 0 // Otherwise WaypointIndex stays 0 and the next AdvanceTick snaps (x,y) to waypoint[0]
// (departure town), which looks like "teleport back to the city" on reload.
// Position the hero at the start of the road if they're very close to the origin town. hm.snapProgressToNearestPointOnRoad()
if len(jitteredWaypoints) > 0 { }
start := jitteredWaypoints[0]
dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y) // snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y
if dist < 5.0 { // to the closest point on the current road polyline to the incoming position.
hm.CurrentX = start.X func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
hm.CurrentY = start.Y if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
hm.WaypointIndex = 0
hm.WaypointFraction = 0
return
}
hx, hy := hm.CurrentX, hm.CurrentY
bestIdx := 0
bestT := 0.0
bestDistSq := math.MaxFloat64
bestX, bestY := hx, hy
for i := 0; i < len(hm.Road.Waypoints)-1; i++ {
ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y
bx, by := hm.Road.Waypoints[i+1].X, hm.Road.Waypoints[i+1].Y
dx, dy := bx-ax, by-ay
segLenSq := dx*dx + dy*dy
var t float64
if segLenSq < 1e-12 {
t = 0
} else {
t = ((hx-ax)*dx + (hy-ay)*dy) / segLenSq
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
}
px := ax + t*dx
py := ay + t*dy
dSq := (hx-px)*(hx-px) + (hy-py)*(hy-py)
if dSq < bestDistSq {
bestDistSq = dSq
bestIdx = i
bestT = t
bestX, bestY = px, py
} }
} }
hm.WaypointIndex = bestIdx
hm.WaypointFraction = bestT
hm.CurrentX = bestX
hm.CurrentY = bestY
} }
// refreshSpeed recalculates the effective movement speed using hero buffs/debuffs. // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.

@ -39,8 +39,9 @@ type Hub struct {
OnConnect func(heroID int64) OnConnect func(heroID int64)
// OnDisconnect is called when a client is unregistered. // OnDisconnect is called when a client is unregistered.
// Set by the engine to persist state and remove movement. May be nil. // remainingSameHero is how many other WS clients for this hero are still connected.
OnDisconnect func(heroID int64) // Set by the engine to persist state; may be nil.
OnDisconnect func(heroID int64, remainingSameHero int)
} }
// Client represents a single WebSocket connection. // Client represents a single WebSocket connection.
@ -87,17 +88,27 @@ func (h *Hub) Run() {
} }
case client := <-h.unregister: case client := <-h.unregister:
heroID := client.heroID
h.mu.Lock() h.mu.Lock()
existed := false
if _, ok := h.clients[client]; ok { if _, ok := h.clients[client]; ok {
delete(h.clients, client) delete(h.clients, client)
existed = true
close(client.send) close(client.send)
} }
remaining := 0
for c := range h.clients {
if c.heroID == heroID {
remaining++
}
}
h.mu.Unlock() h.mu.Unlock()
h.logger.Info("client disconnected", "hero_id", client.heroID) h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining)
// Notify engine of disconnection. // Always persist; engine drops in-memory movement only when remaining == 0.
if h.OnDisconnect != nil { // Synchronous so a reconnect that loads from DB sees the latest save.
go h.OnDisconnect(client.heroID) if existed && h.OnDisconnect != nil {
h.OnDisconnect(heroID, remaining)
} }
case env := <-h.broadcast: case env := <-h.broadcast:

Loading…
Cancel
Save