diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index bd11d1b..293d372 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -356,6 +356,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { hm := NewHeroMovement(hero, e.roadGraph, now) e.movements[hero.ID] = hm + hm.SyncToHero() e.logger.Info("hero movement registered", "hero_id", hero.ID, @@ -366,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) diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 84bc4c1..7211987 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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. @@ -90,26 +94,25 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov } // 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). Only spread - // new heroes that still sit at the default origin. + // lateral jitter again on reconnect (that doubled the shift every reload). + freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0 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 + 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: curX, - CurrentY: curY, - State: hero.State, - LastMoveTick: now, - Direction: dir, + HeroID: hero.ID, + Hero: hero, + CurrentX: curX, + CurrentY: curY, + State: hero.State, + LastMoveTick: now, + Direction: dir, + spawnAtRoadStart: freshWorldSpawn, } // Restore persisted movement state. @@ -287,10 +290,17 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) { } hm.Road = jitteredRoad - // Restore progress along this hero's jittered polyline from saved world position. - // 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. - hm.snapProgressToNearestPointOnRoad() + 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() + } } // snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y @@ -532,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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6200859..4645fe9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -272,6 +272,7 @@ export function App() { lastVictoryLoot: null, tick: 0, serverTimeMs: 0, + routeWaypoints: null, }); const [damages, setDamages] = useState([]); @@ -1223,6 +1224,7 @@ export function App() { heroX={gameState.hero.position.x} heroY={gameState.hero.position.y} towns={towns} + routeWaypoints={gameState.routeWaypoints} /> )} diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 977a4b9..ea7b86c 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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(); } diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 2bd5d46..2327bbd 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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) ---- diff --git a/frontend/src/ui/Minimap.tsx b/frontend/src/ui/Minimap.tsx index f0c6c57..8b8f8a5 100644 --- a/frontend/src/ui/Minimap.tsx +++ b/frontend/src/ui/Minimap.tsx @@ -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 = { 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( + 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(1); const canvasRef = useRef(null); const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null); + const lastRouteKey = useRef(''); + const lastTownsKey = useRef(''); 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); - 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(); + : 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); + } 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 (