Compare commits

..

2 Commits

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

@ -329,8 +329,34 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
}
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)
e.movements[hero.ID] = hm
hm.SyncToHero()
e.logger.Info("hero movement registered",
"hero_id", hero.ID,
@ -341,8 +367,8 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// Send initial state via WS.
if e.sender != nil {
hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hero)
hm.Hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
if route := hm.RoutePayload(); route != nil {
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.
// Called when a WS client disconnects.
func (e *Engine) UnregisterHeroMovement(heroID int64) {
// HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
// movement only when lastConnection is true (no other tabs/sockets for this hero).
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
e.mu.Lock()
hm, ok := e.movements[heroID]
if ok {
hm.SyncToHero()
if lastConnection {
delete(e.movements, heroID)
}
}
var heroSnap *model.Hero
if ok {
heroSnap = hm.Hero
}
e.mu.Unlock()
if ok && e.heroStore != nil {
if ok && e.heroStore != nil && heroSnap != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
e.logger.Error("failed to save hero on disconnect", "hero_id", heroID, "error", err)
if err := e.heroStore.Save(ctx, heroSnap); err != nil {
e.logger.Error("failed to save hero on ws disconnect", "hero_id", heroID, "error", err)
} 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
// 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 = 15 * time.Minute
@ -78,6 +78,10 @@ type HeroMovement struct {
AdventureStartAt time.Time
AdventureEndAt time.Time
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.
@ -89,18 +93,26 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
dir = -1
}
// Add per-hero position offset so heroes on the same road don't overlap.
// Use hero ID to create a stable lateral offset of ±1.5 tiles.
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5
// Persisted (x,y) already include any in-world offset from prior sessions; do not add
// lateral jitter again on reconnect (that doubled the shift every reload).
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{
HeroID: hero.ID,
Hero: hero,
CurrentX: hero.PositionX + lateralOffset*0.3,
CurrentY: hero.PositionY + lateralOffset*0.7,
CurrentX: curX,
CurrentY: curY,
State: hero.State,
LastMoveTick: now,
Direction: dir,
spawnAtRoadStart: freshWorldSpawn,
}
// Restore persisted movement state.
@ -278,18 +290,63 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
}
hm.Road = jitteredRoad
if hm.spawnAtRoadStart {
wp0 := jitteredRoad.Waypoints[0]
hm.CurrentX = wp0.X
hm.CurrentY = wp0.Y
hm.WaypointIndex = 0
hm.WaypointFraction = 0
hm.spawnAtRoadStart = false
} else {
// Restore progress along this hero's jittered polyline from saved world position.
hm.snapProgressToNearestPointOnRoad()
}
}
// Position the hero at the start of the road if they're very close to the origin town.
if len(jitteredWaypoints) > 0 {
start := jitteredWaypoints[0]
dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y)
if dist < 5.0 {
hm.CurrentX = start.X
hm.CurrentY = start.Y
// 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.
@ -485,12 +542,14 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy mo
return false, model.Enemy{}, false
}
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 {
return false, model.Enemy{}, false
}
monsterW := 0.08 + 0.92*w*w
merchantW := 0.08 + 0.92*(1-w)*(1-w)
// On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters.
monsterW := 0.62 + 0.18*w*w
merchantW := 0.04 + 0.10*(1-w)*(1-w)
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {

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

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

@ -76,6 +76,7 @@ export class GameEngine {
lastVictoryLoot: null,
tick: 0,
serverTimeMs: 0,
routeWaypoints: null,
};
// ---- Server-driven position interpolation ----
@ -296,7 +297,11 @@ export class GameEngine {
this._routeWaypoints = waypoints;
this._heroSpeed = speed;
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._notifyStateChange();
}

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

@ -8,6 +8,7 @@ interface MinimapProps {
heroX: number;
heroY: number;
towns: Town[];
routeWaypoints: Array<{ x: number; y: number }> | null;
}
/** 0 = свернуто, 1 = маленькая, 2 = большая */
@ -39,6 +40,20 @@ const TERRAIN_BG: Record<string, string> = {
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 ----
const containerStyle: CSSProperties = {
@ -89,11 +104,13 @@ function modeLabel(mode: MapMode): string {
// ---- Component ----
export function Minimap({ heroX, heroY, towns }: MinimapProps) {
export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const [mode, setMode] = useState<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN });
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 collapsed = mode === 0;
@ -102,12 +119,26 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
if (!collapsed) {
lastDrawPos.current = { x: NaN, y: NaN };
lastHeroTile.current = null;
lastRouteKey.current = '';
lastTownsKey.current = '';
}
}, [collapsed, size]);
useEffect(() => {
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 tileY = Math.floor(heroY);
const last = lastDrawPos.current;
@ -115,7 +146,17 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
const dy = Math.abs(heroY - last.y);
const lt = lastHeroTile.current;
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;
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 glowR = Math.max(8, Math.round(minDim * 0.09));
const activeRoute =
routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null;
const miniCtx =
towns.length === 0
? null
: buildWorldTerrainContext(townsApiToInfluences(towns), null);
const terrain = proceduralTerrain(tileX, tileY, miniCtx);
: buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute);
if (!miniCtx || miniCtx.towns.length === 0) {
const terrain = proceduralTerrain(tileX, tileY, null);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(180, 170, 140, 0.4)';
ctx.lineWidth = Math.max(1, minDim / 70);
ctx.beginPath();
const diagOff = minDim * 0.06;
ctx.moveTo(0, cy + diagOff);
ctx.lineTo(w, cy - diagOff);
ctx.stroke();
} else {
const img = ctx.createImageData(w, h);
const data = img.data;
let q = 0;
const scale = WORLD_UNITS_PER_PX;
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.lineWidth = 0.5;
@ -209,7 +265,7 @@ export function Minimap({ heroX, heroY, towns }: MinimapProps) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
}, [heroX, heroY, towns, collapsed, size]);
}, [heroX, heroY, towns, routeWaypoints, collapsed, size]);
return (
<div style={containerStyle}>

Loading…
Cancel
Save