fix event chances, fix new hero init stack bug

master
Denis Ranneft 9 hours ago
parent 1fdfdbfcda
commit 109045a6e2

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

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

@ -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);
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 (
<div style={containerStyle}>

Loading…
Cancel
Save