Compare commits

..

No commits in common. '109045a6e2011faded7f8d46ee09f2d106aa51b1' and '867a00bf8c8b90c114f1f47e6a1dd46ea5a4ebfe' have entirely different histories.

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

@ -329,34 +329,8 @@ 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,
@ -367,8 +341,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 {
hm.Hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) e.sender.SendToHero(hero.ID, "hero_state", 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)
@ -383,33 +357,24 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
} }
} }
// HeroSocketDetached persists hero state on every WS disconnect and removes in-memory // UnregisterHeroMovement removes movement state and persists hero to DB.
// movement only when lastConnection is true (no other tabs/sockets for this hero). // Called when a WS client disconnects.
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) { func (e *Engine) UnregisterHeroMovement(heroID int64) {
e.mu.Lock() e.mu.Lock()
hm, ok := e.movements[heroID] hm, ok := e.movements[heroID]
if ok { if ok {
hm.SyncToHero() hm.SyncToHero()
if lastConnection {
delete(e.movements, heroID) delete(e.movements, heroID)
} }
}
var heroSnap *model.Hero
if ok {
heroSnap = hm.Hero
}
e.mu.Unlock() e.mu.Unlock()
if ok && e.heroStore != nil && heroSnap != nil { if ok && e.heroStore != 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, heroSnap); err != nil { if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
e.logger.Error("failed to save hero on ws disconnect", "hero_id", heroID, "error", err) e.logger.Error("failed to save hero on disconnect", "hero_id", heroID, "error", err)
} else { } else {
e.logger.Info("hero state persisted on ws disconnect", e.logger.Info("hero state persisted on disconnect", "hero_id", heroID)
"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.0021 StartAdventurePerTick = 0.0004
// 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,10 +78,6 @@ 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.
@ -93,26 +89,18 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
dir = -1 dir = -1
} }
// Persisted (x,y) already include any in-world offset from prior sessions; do not add // Add per-hero position offset so heroes on the same road don't overlap.
// lateral jitter again on reconnect (that doubled the shift every reload). // Use hero ID to create a stable lateral offset of ±1.5 tiles.
freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0 lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5
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: curX, CurrentX: hero.PositionX + lateralOffset*0.3,
CurrentY: curY, CurrentY: hero.PositionY + lateralOffset*0.7,
State: hero.State, State: hero.State,
LastMoveTick: now, LastMoveTick: now,
Direction: dir, Direction: dir,
spawnAtRoadStart: freshWorldSpawn,
} }
// Restore persisted movement state. // Restore persisted movement state.
@ -290,63 +278,18 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
} }
hm.Road = jitteredRoad hm.Road = jitteredRoad
if hm.spawnAtRoadStart {
wp0 := jitteredRoad.Waypoints[0]
hm.CurrentX = wp0.X
hm.CurrentY = wp0.Y
hm.WaypointIndex = 0 hm.WaypointIndex = 0
hm.WaypointFraction = 0 hm.WaypointFraction = 0
hm.spawnAtRoadStart = false
} else {
// Restore progress along this hero's jittered polyline from saved world position.
hm.snapProgressToNearestPointOnRoad()
}
}
// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y // Position the hero at the start of the road if they're very close to the origin town.
// to the closest point on the current road polyline to the incoming position. if len(jitteredWaypoints) > 0 {
func (hm *HeroMovement) snapProgressToNearestPointOnRoad() { start := jitteredWaypoints[0]
if hm.Road == nil || len(hm.Road.Waypoints) < 2 { dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y)
hm.WaypointIndex = 0 if dist < 5.0 {
hm.WaypointFraction = 0 hm.CurrentX = start.X
return hm.CurrentY = start.Y
}
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.
@ -542,14 +485,12 @@ 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)
// More encounter checks on the road; still ramps up further from the road. activity := EncounterActivityBase * (0.45 + 0.55*w)
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
} }
// On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters. monsterW := 0.08 + 0.92*w*w
monsterW := 0.62 + 0.18*w*w merchantW := 0.08 + 0.92*(1-w)*(1-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,9 +39,8 @@ 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.
// remainingSameHero is how many other WS clients for this hero are still connected. // Set by the engine to persist state and remove movement. May be nil.
// Set by the engine to persist state; may be nil. OnDisconnect func(heroID int64)
OnDisconnect func(heroID int64, remainingSameHero int)
} }
// Client represents a single WebSocket connection. // Client represents a single WebSocket connection.
@ -88,27 +87,17 @@ 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", heroID, "remaining_same_hero", remaining) h.logger.Info("client disconnected", "hero_id", client.heroID)
// Always persist; engine drops in-memory movement only when remaining == 0. // Notify engine of disconnection.
// Synchronous so a reconnect that loads from DB sees the latest save. if h.OnDisconnect != nil {
if existed && h.OnDisconnect != nil { go h.OnDisconnect(client.heroID)
h.OnDisconnect(heroID, remaining)
} }
case env := <-h.broadcast: case env := <-h.broadcast:

@ -272,7 +272,6 @@ 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[]>([]);
@ -1224,7 +1223,6 @@ 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,7 +76,6 @@ 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 ----
@ -297,11 +296,7 @@ export class GameEngine {
this._routeWaypoints = waypoints; this._routeWaypoints = waypoints;
this._heroSpeed = speed; this._heroSpeed = speed;
this._syncWorldTerrainContext(); this._syncWorldTerrainContext();
this._gameState = { this._gameState = { ...this._gameState, phase: GamePhase.Walking };
...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,8 +188,6 @@ 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,7 +8,6 @@ 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 = большая */
@ -40,20 +39,6 @@ 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 = {
@ -104,13 +89,11 @@ function modeLabel(mode: MapMode): string {
// ---- Component ---- // ---- Component ----
export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { export function Minimap({ heroX, heroY, towns }: 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;
@ -119,26 +102,12 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: 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;
@ -146,17 +115,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: 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 ( if (!tileChanged && dx < REDRAW_THRESHOLD && dy < REDRAW_THRESHOLD) return;
!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;
@ -178,36 +137,21 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: 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), activeRoute); : buildWorldTerrainContext(townsApiToInfluences(towns), null);
const terrain = proceduralTerrain(tileX, tileY, miniCtx);
if (!miniCtx || miniCtx.towns.length === 0) {
const terrain = proceduralTerrain(tileX, tileY, null);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG; ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
} else {
const img = ctx.createImageData(w, h); ctx.strokeStyle = 'rgba(180, 170, 140, 0.4)';
const data = img.data; ctx.lineWidth = Math.max(1, minDim / 70);
let q = 0; ctx.beginPath();
const scale = WORLD_UNITS_PER_PX; const diagOff = minDim * 0.06;
for (let py = 0; py < h; py++) { ctx.moveTo(0, cy + diagOff);
const wy = Math.floor(heroY + (py - cy) * scale); ctx.lineTo(w, cy - diagOff);
for (let px = 0; px < w; px++) { ctx.stroke();
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;
@ -265,7 +209,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
ctx.strokeStyle = '#ffffff'; ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
}, [heroX, heroY, towns, routeWaypoints, collapsed, size]); }, [heroX, heroY, towns, collapsed, size]);
return ( return (
<div style={containerStyle}> <div style={containerStyle}>

Loading…
Cancel
Save