fix event chances, fix new hero init stack bug

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

@ -356,6 +356,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
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,
@ -366,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)

@ -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.
@ -90,13 +94,11 @@ 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 // 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 // lateral jitter again on reconnect (that doubled the shift every reload).
// new heroes that still sit at the default origin. freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0
var curX, curY float64 var curX, curY float64
if hero.PositionX == 0 && hero.PositionY == 0 { if freshWorldSpawn {
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road
curX = lateralOffset * 0.3
curY = lateralOffset * 0.7
} else { } else {
curX = hero.PositionX curX = hero.PositionX
curY = hero.PositionY curY = hero.PositionY
@ -110,6 +112,7 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
State: hero.State, State: hero.State,
LastMoveTick: now, LastMoveTick: now,
Direction: dir, Direction: dir,
spawnAtRoadStart: freshWorldSpawn,
} }
// Restore persisted movement state. // Restore persisted movement state.
@ -287,10 +290,17 @@ 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.WaypointFraction = 0
hm.spawnAtRoadStart = false
} else {
// Restore progress along this hero's jittered polyline from saved world position. // 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() hm.snapProgressToNearestPointOnRoad()
}
} }
// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y // 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 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 {

@ -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);
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 {
ctx.strokeStyle = 'rgba(180, 170, 140, 0.4)'; const img = ctx.createImageData(w, h);
ctx.lineWidth = Math.max(1, minDim / 70); const data = img.data;
ctx.beginPath(); let q = 0;
const diagOff = minDim * 0.06; const scale = WORLD_UNITS_PER_PX;
ctx.moveTo(0, cy + diagOff); for (let py = 0; py < h; py++) {
ctx.lineTo(w, cy - diagOff); const wy = Math.floor(heroY + (py - cy) * scale);
ctx.stroke(); 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