Compare commits

...

2 Commits

@ -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,8 +329,34 @@ 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
hm.SyncToHero()
e.logger.Info("hero movement registered", e.logger.Info("hero movement registered",
"hero_id", hero.ID, "hero_id", hero.ID,
@ -341,8 +367,8 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// Send initial state via WS. // Send initial state via WS.
if e.sender != nil { if e.sender != nil {
hero.RefreshDerivedCombatStats(now) hm.Hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hero) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
if route := hm.RoutePayload(); route != nil { if route := hm.RoutePayload(); route != nil {
e.sender.SendToHero(hero.ID, "route_assigned", route) e.sender.SendToHero(hero.ID, "route_assigned", route)
@ -357,24 +383,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,
)
} }
} }
} }

@ -26,7 +26,7 @@ const (
EncounterActivityBase = 0.035 EncounterActivityBase = 0.035
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion. // StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion.
StartAdventurePerTick = 0.0004 StartAdventurePerTick = 0.0021
// AdventureDurationMin/Max bound how long an off-road excursion lasts. // AdventureDurationMin/Max bound how long an off-road excursion lasts.
AdventureDurationMin = 15 * time.Minute AdventureDurationMin = 15 * time.Minute
@ -78,6 +78,10 @@ type HeroMovement struct {
AdventureStartAt time.Time AdventureStartAt time.Time
AdventureEndAt time.Time AdventureEndAt time.Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
// spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad
// instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
spawnAtRoadStart bool
} }
// NewHeroMovement creates a HeroMovement for a hero that just connected. // NewHeroMovement creates a HeroMovement for a hero that just connected.
@ -89,18 +93,26 @@ 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).
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0
var curX, curY float64
if freshWorldSpawn {
curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road
} 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,
spawnAtRoadStart: freshWorldSpawn,
} }
// Restore persisted movement state. // Restore persisted movement state.
@ -278,18 +290,63 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
} }
hm.Road = jitteredRoad hm.Road = jitteredRoad
hm.WaypointIndex = 0 if hm.spawnAtRoadStart {
hm.WaypointFraction = 0 wp0 := jitteredRoad.Waypoints[0]
hm.CurrentX = wp0.X
// Position the hero at the start of the road if they're very close to the origin town. hm.CurrentY = wp0.Y
if len(jitteredWaypoints) > 0 { hm.WaypointIndex = 0
start := jitteredWaypoints[0] hm.WaypointFraction = 0
dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y) hm.spawnAtRoadStart = false
if dist < 5.0 { } else {
hm.CurrentX = start.X // Restore progress along this hero's jittered polyline from saved world position.
hm.CurrentY = start.Y hm.snapProgressToNearestPointOnRoad()
}
}
// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y
// to the closest point on the current road polyline to the incoming position.
func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
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.
@ -485,12 +542,14 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy mo
return false, model.Enemy{}, false return false, model.Enemy{}, false
} }
w := hm.wildernessFactor(now) w := hm.wildernessFactor(now)
activity := EncounterActivityBase * (0.45 + 0.55*w) // More encounter checks on the road; still ramps up further from the road.
activity := EncounterActivityBase * (0.62 + 0.38*w)
if rand.Float64() >= activity { if rand.Float64() >= activity {
return false, model.Enemy{}, false return false, model.Enemy{}, false
} }
monsterW := 0.08 + 0.92*w*w // On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters.
merchantW := 0.08 + 0.92*(1-w)*(1-w) monsterW := 0.62 + 0.18*w*w
merchantW := 0.04 + 0.10*(1-w)*(1-w)
total := monsterW + merchantW total := monsterW + merchantW
r := rand.Float64() * total r := rand.Float64() * total
if r < monsterW { if r < monsterW {

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

@ -272,6 +272,7 @@ export function App() {
lastVictoryLoot: null, lastVictoryLoot: null,
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null,
}); });
const [damages, setDamages] = useState<FloatingDamageData[]>([]); const [damages, setDamages] = useState<FloatingDamageData[]>([]);
@ -1223,6 +1224,7 @@ export function App() {
heroX={gameState.hero.position.x} heroX={gameState.hero.position.x}
heroY={gameState.hero.position.y} heroY={gameState.hero.position.y}
towns={towns} towns={towns}
routeWaypoints={gameState.routeWaypoints}
/> />
)} )}

@ -76,6 +76,7 @@ export class GameEngine {
lastVictoryLoot: null, lastVictoryLoot: null,
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null,
}; };
// ---- Server-driven position interpolation ---- // ---- Server-driven position interpolation ----
@ -296,7 +297,11 @@ export class GameEngine {
this._routeWaypoints = waypoints; this._routeWaypoints = waypoints;
this._heroSpeed = speed; this._heroSpeed = speed;
this._syncWorldTerrainContext(); this._syncWorldTerrainContext();
this._gameState = { ...this._gameState, phase: GamePhase.Walking }; this._gameState = {
...this._gameState,
phase: GamePhase.Walking,
routeWaypoints: waypoints.length >= 2 ? waypoints.map((p) => ({ x: p.x, y: p.y })) : null,
};
this._thoughtText = null; this._thoughtText = null;
this._notifyStateChange(); this._notifyStateChange();
} }

@ -188,6 +188,8 @@ export interface GameState {
lastVictoryLoot: LootDrop | null; lastVictoryLoot: LootDrop | null;
tick: number; tick: number;
serverTimeMs: number; serverTimeMs: number;
/** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */
routeWaypoints: Array<{ x: number; y: number }> | null;
} }
// ---- Rendering State (interpolated) ---- // ---- Rendering State (interpolated) ----

@ -8,6 +8,7 @@ interface MinimapProps {
heroX: number; heroX: number;
heroY: number; heroY: number;
towns: Town[]; towns: Town[];
routeWaypoints: Array<{ x: number; y: number }> | null;
} }
/** 0 = свернуто, 1 = маленькая, 2 = большая */ /** 0 = свернуто, 1 = маленькая, 2 = большая */
@ -39,6 +40,20 @@ const TERRAIN_BG: Record<string, string> = {
const DEFAULT_BG = '#1e2420'; const DEFAULT_BG = '#1e2420';
function hexToRgb(hex: string): [number, number, number] {
const s = hex.replace('#', '');
return [
parseInt(s.slice(0, 2), 16),
parseInt(s.slice(2, 4), 16),
parseInt(s.slice(4, 6), 16),
];
}
const TERRAIN_RGB = new Map<string, [number, number, number]>(
Object.entries(TERRAIN_BG).map(([k, v]) => [k, hexToRgb(v)]),
);
const DEFAULT_RGB = hexToRgb(DEFAULT_BG);
// ---- Styles ---- // ---- Styles ----
const containerStyle: CSSProperties = { const containerStyle: CSSProperties = {
@ -89,11 +104,13 @@ function modeLabel(mode: MapMode): string {
// ---- Component ---- // ---- Component ----
export function Minimap({ heroX, heroY, towns }: MinimapProps) { export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const [mode, setMode] = useState<MapMode>(1); const [mode, setMode] = useState<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN });
const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null); const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null);
const lastRouteKey = useRef<string>('');
const lastTownsKey = useRef<string>('');
const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL; const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL;
const collapsed = mode === 0; const collapsed = mode === 0;
@ -102,12 +119,26 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
if (!collapsed) { if (!collapsed) {
lastDrawPos.current = { x: NaN, y: NaN }; lastDrawPos.current = { x: NaN, y: NaN };
lastHeroTile.current = null; lastHeroTile.current = null;
lastRouteKey.current = '';
lastTownsKey.current = '';
} }
}, [collapsed, size]); }, [collapsed, size]);
useEffect(() => { useEffect(() => {
if (collapsed) return; if (collapsed) return;
const routeKey =
routeWaypoints && routeWaypoints.length >= 2
? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';')
: '';
const routeChanged = routeKey !== lastRouteKey.current;
const townsKey =
towns.length === 0
? ''
: towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}`).join(';');
const townsChanged = townsKey !== lastTownsKey.current;
const tileX = Math.floor(heroX); const tileX = Math.floor(heroX);
const tileY = Math.floor(heroY); const tileY = Math.floor(heroY);
const last = lastDrawPos.current; const last = lastDrawPos.current;
@ -115,7 +146,17 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
const dy = Math.abs(heroY - last.y); const dy = Math.abs(heroY - last.y);
const lt = lastHeroTile.current; const lt = lastHeroTile.current;
const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY; const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY;
if (!tileChanged && dx < REDRAW_THRESHOLD && dy < REDRAW_THRESHOLD) return; if (
!routeChanged &&
!townsChanged &&
!tileChanged &&
dx < REDRAW_THRESHOLD &&
dy < REDRAW_THRESHOLD
) {
return;
}
lastRouteKey.current = routeKey;
lastTownsKey.current = townsKey;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
@ -137,21 +178,36 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
const heroR = Math.max(3, Math.round(minDim * 0.035)); const heroR = Math.max(3, Math.round(minDim * 0.035));
const glowR = Math.max(8, Math.round(minDim * 0.09)); const glowR = Math.max(8, Math.round(minDim * 0.09));
const activeRoute =
routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null;
const miniCtx = const miniCtx =
towns.length === 0 towns.length === 0
? null ? null
: buildWorldTerrainContext(townsApiToInfluences(towns), null); : buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute);
const terrain = proceduralTerrain(tileX, tileY, miniCtx);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG; if (!miniCtx || miniCtx.towns.length === 0) {
ctx.fillRect(0, 0, w, h); const terrain = proceduralTerrain(tileX, tileY, null);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG;
ctx.strokeStyle = 'rgba(180, 170, 140, 0.4)'; ctx.fillRect(0, 0, w, h);
ctx.lineWidth = Math.max(1, minDim / 70); } else {
ctx.beginPath(); const img = ctx.createImageData(w, h);
const diagOff = minDim * 0.06; const data = img.data;
ctx.moveTo(0, cy + diagOff); let q = 0;
ctx.lineTo(w, cy - diagOff); const scale = WORLD_UNITS_PER_PX;
ctx.stroke(); for (let py = 0; py < h; py++) {
const wy = Math.floor(heroY + (py - cy) * scale);
for (let px = 0; px < w; px++) {
const wx = Math.floor(heroX + (px - cx) * scale);
const terrain = proceduralTerrain(wx, wy, miniCtx);
const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB;
data[q++] = rgb[0];
data[q++] = rgb[1];
data[q++] = rgb[2];
data[q++] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
@ -209,7 +265,7 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
ctx.strokeStyle = '#ffffff'; ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
}, [heroX, heroY, towns, collapsed, size]); }, [heroX, heroY, towns, routeWaypoints, collapsed, size]);
return ( return (
<div style={containerStyle}> <div style={containerStyle}>

Loading…
Cancel
Save