adjustments for the rest and encounters in return phase

master
Denis Ranneft 1 month ago
parent cbab3dbe3b
commit 8f6feaa6b2

@ -1350,6 +1350,22 @@ func (e *Engine) processMovementTick(now time.Time) {
for heroID, hm := range e.movements { for heroID, hm := range e.movements {
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter) ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter)
if e.heroStore == nil || hm == nil || hm.Hero == nil {
continue
}
if sig, ok := hm.TownPausePersistDue(); ok {
hm.SyncToHero()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := e.heroStore.Save(ctx, hm.Hero)
cancel()
if err != nil {
if e.logger != nil {
e.logger.Error("persist hero excursion/rest failed", "hero_id", heroID, "error", err)
}
continue
}
hm.MarkTownPausePersisted(sig)
}
} }
} }
@ -1378,13 +1394,14 @@ func (e *Engine) processPositionSync(now time.Time) {
e.mu.RLock() e.mu.RLock()
sender := e.sender sender := e.sender
for heroID, hm := range e.movements { for heroID, hm := range e.movements {
if hm.State != model.StateWalking { if hm == nil {
continue continue
} }
if sender != nil { if sender != nil && hm.State == model.StateWalking {
sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
} }
if hm.Hero != nil { shouldPersistPos := hm.State == model.StateWalking || hm.State == model.StateResting || hm.Excursion.Active()
if shouldPersistPos && hm.Hero != nil {
hm.SyncToHero() hm.SyncToHero()
snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY}) snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY})
} }

@ -95,6 +95,26 @@ type HeroMovement struct {
// spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad // 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. // instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
spawnAtRoadStart bool spawnAtRoadStart bool
// lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can
// persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature
}
// townPausePersistSignature captures the excursion/rest fields that should trigger persistence.
// Keep this small to avoid persisting every tick due to healing remainders.
type townPausePersistSignature struct {
RestKind model.RestKind
RestUntil time.Time
ExcursionPhase model.ExcursionPhase
ExcursionStartedAt time.Time
ExcursionOutUntil time.Time
ExcursionWildUntil time.Time
ExcursionReturnUntil time.Time
ExcursionDepthWorldUnits float64
ExcursionRoadFreezeWaypoint int
ExcursionRoadFreezeFraction float64
} }
// NewHeroMovement creates a HeroMovement for a hero that just connected. // NewHeroMovement creates a HeroMovement for a hero that just connected.
@ -925,9 +945,53 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.RestKind = model.RestKindTown hm.Hero.RestKind = model.RestKindTown
} }
} }
hm.Hero.ExcursionPhase = model.ExcursionNone
if hm.Excursion.Active() {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
}
hm.Hero.TownPause = hm.townPauseBlob() hm.Hero.TownPause = hm.townPauseBlob()
} }
// TownPausePersistDue reports whether excursion/rest state should be persisted.
// Returns the current signature for use when marking persistence.
func (hm *HeroMovement) TownPausePersistDue() (townPausePersistSignature, bool) {
sig := hm.townPausePersistSignature()
if sig == hm.lastTownPausePersistSignature {
return sig, false
}
return sig, true
}
// MarkTownPausePersisted stores the latest persisted signature.
func (hm *HeroMovement) MarkTownPausePersisted(sig townPausePersistSignature) {
hm.lastTownPausePersistSignature = sig
}
func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
var sig townPausePersistSignature
if hm.State == model.StateResting {
rk := hm.ActiveRestKind
if rk == model.RestKindNone {
rk = model.RestKindTown
}
sig.RestKind = rk
sig.RestUntil = hm.RestUntil
}
if hm.Excursion.Active() {
s := hm.Excursion
sig.ExcursionPhase = s.Phase
sig.ExcursionStartedAt = s.StartedAt
sig.ExcursionOutUntil = s.OutUntil
sig.ExcursionWildUntil = s.WildUntil
sig.ExcursionReturnUntil = s.ReturnUntil
sig.ExcursionDepthWorldUnits = s.DepthWorldUnits
sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint
sig.ExcursionRoadFreezeFraction = s.RoadFreezeFraction
}
return sig
}
func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
var p *model.TownPausePersisted var p *model.TownPausePersisted
@ -1241,6 +1305,35 @@ func clamp01(v float64) float64 {
return v return v
} }
func (hm *HeroMovement) excursionWildness(now time.Time) float64 {
if hm.Excursion.Phase == model.ExcursionWild {
return 1
}
if hm.Excursion.Phase == model.ExcursionReturn {
retMs := float64(hm.Excursion.ReturnUntil.Sub(hm.Excursion.WildUntil).Milliseconds())
var t float64
if retMs > 0 {
elapsed := float64(now.Sub(hm.Excursion.WildUntil).Milliseconds())
t = 1.0 - smoothstep(clamp01(elapsed/retMs))
} else {
t = 1.0
}
cfg := tuning.Get()
minWild := cfg.AdventureReturnWildnessMin
if minWild < 0 {
minWild = 0
}
if minWild > 1 {
minWild = 1
}
if t < minWild {
t = minWild
}
return t
}
return 0
}
func (hm *HeroMovement) isLowHP() bool { func (hm *HeroMovement) isLowHP() bool {
if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 { if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 {
return false return false
@ -1412,7 +1505,8 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, ene
if rand.Float64() >= cfg.EncounterActivityBase { if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false return false, model.Enemy{}, false
} }
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus wildness := hm.excursionWildness(now)
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness
merchantW := cfg.MerchantEncounterWeightBase merchantW := cfg.MerchantEncounterWeightBase
total := monsterW + merchantW total := monsterW + merchantW
r := rand.Float64() * total r := rand.Float64() * total

@ -65,6 +65,8 @@ type Hero struct {
DestinationTownID *int64 `json:"destinationTownId,omitempty"` DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"` MoveState string `json:"moveState"`
RestKind RestKind `json:"restKind,omitempty"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only). // TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`

@ -173,6 +173,8 @@ type Values struct {
AdventureEncounterCooldownMs int64 `json:"adventureEncounterCooldownMs"` AdventureEncounterCooldownMs int64 `json:"adventureEncounterCooldownMs"`
// AdventureReturnEncounterEnabled allows encounters during the return phase. // AdventureReturnEncounterEnabled allows encounters during the return phase.
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"` AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"`
// --- HP-based rest triggers --- // --- HP-based rest triggers ---
@ -328,6 +330,7 @@ func DefaultValues() Values {
AdventureDepthWorldUnits: 40.0, AdventureDepthWorldUnits: 40.0,
AdventureEncounterCooldownMs: 6_000, AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true, AdventureReturnEncounterEnabled: true,
AdventureReturnWildnessMin: 0.35,
LowHpThreshold: 0.25, LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.70, RoadsideRestExitHp: 0.70,

@ -236,6 +236,7 @@ function heroResponseToState(res: HeroResponse): HeroState {
position: { x: res.positionX ?? 0, y: res.positionY ?? 0 }, position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
serverActivityState: res.state, serverActivityState: res.state,
restKind: res.restKind, restKind: res.restKind,
excursionPhase: res.excursionPhase,
attackSpeed: res.attackSpeed ?? res.speed, attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack, damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense, defense: res.defensePower ?? res.defense,

@ -777,7 +777,18 @@ export class GameEngine {
now, now,
); );
this.renderer.clearCampfire(); const rk = state.hero.restKind?.toLowerCase() ?? '';
const wild =
state.hero.excursionPhase?.toLowerCase() === 'wild';
const showRestCamp =
state.phase === GamePhase.Resting &&
wild &&
(rk === 'roadside' || rk === 'adventure_inline');
if (showRestCamp) {
this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now);
} else {
this.renderer.clearRestCamp();
}
// Thought bubble during rest/town pauses // Thought bubble during rest/town pauses
if (this._thoughtText) { if (this._thoughtText) {
@ -792,7 +803,7 @@ export class GameEngine {
this.renderer.clearThoughtBubble(); this.renderer.clearThoughtBubble();
} }
} else { } else {
this.renderer.clearCampfire(); this.renderer.clearRestCamp();
} }
// Draw NPCs from towns // Draw NPCs from towns

@ -77,7 +77,8 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path) // Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null; private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null; private _heroGfx: Graphics | null = null;
private _campfireGfx: Graphics | null = null; /** Tent + campfire while resting in the wild phase (roadside / adventure inline). */
private _restCampGfx: Graphics | null = null;
private _enemyGfx: Graphics | null = null; private _enemyGfx: Graphics | null = null;
private _thoughtGfx: Graphics | null = null; private _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null; private _thoughtText: Text | null = null;
@ -317,8 +318,8 @@ export class GameRenderer {
this._heroGfx = new Graphics(); this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx); this.entityLayer.addChild(this._heroGfx);
this._campfireGfx = new Graphics(); this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._campfireGfx); this.entityLayer.addChild(this._restCampGfx);
this._enemyGfx = new Graphics(); this._enemyGfx = new Graphics();
this.entityLayer.addChild(this._enemyGfx); this.entityLayer.addChild(this._enemyGfx);
@ -560,30 +561,58 @@ export class GameRenderer {
} }
} }
/** Draw a small campfire near hero while roadside-resting. */ /**
drawCampfire(wx: number, wy: number, now: number): void { * Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
const gfx = this._campfireGfx; * Placed slightly behind the hero in screen space for a bivouac read.
*/
drawRestCamp(wx: number, wy: number, now: number): void {
const gfx = this._restCampGfx;
if (!gfx) return; if (!gfx) return;
gfx.clear(); gfx.clear();
const iso = worldToScreen(wx, wy); const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.005) * 1.2; const bob = Math.sin(now * 0.004) * 1.0;
const cx = iso.x + 18;
const cy = iso.y + 9 + bob; // --- Tent (screen-left of hero, reads “behind” in iso) ---
const tx = iso.x - 26;
const ty = iso.y - 4 + bob * 0.4;
// Ground shadow under tent
gfx.ellipse(tx, ty + 14, 22, 7);
gfx.fill({ color: 0x000000, alpha: 0.18 });
// Tent body (trapezoid wall + triangle roof)
gfx.poly([tx - 18, ty + 12, tx + 18, ty + 12, tx + 14, ty - 8, tx - 14, ty - 8]);
gfx.fill({ color: 0x8b6914, alpha: 0.92 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.fill({ color: 0xc4a574, alpha: 0.96 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.stroke({ color: 0x5c4030, width: 1.2, alpha: 0.85 });
gfx.rect(tx - 5, ty + 2, 10, 10);
gfx.fill({ color: 0x1a1510, alpha: 0.55 });
// Guy lines / pegs (tiny)
gfx.moveTo(tx - 18, ty + 12);
gfx.lineTo(tx - 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
gfx.moveTo(tx + 18, ty + 12);
gfx.lineTo(tx + 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
// --- Campfire (near tent / hero) ---
const cx = iso.x + 16;
const cy = iso.y + 10 + bob;
// Ground shadow / ember glow
gfx.ellipse(cx, cy + 6, 12, 4); gfx.ellipse(cx, cy + 6, 12, 4);
gfx.fill({ color: 0x000000, alpha: 0.22 }); gfx.fill({ color: 0x000000, alpha: 0.22 });
gfx.ellipse(cx, cy + 3, 10, 3.2); gfx.ellipse(cx, cy + 3, 10, 3.2);
gfx.fill({ color: 0xff7a1a, alpha: 0.2 }); gfx.fill({ color: 0xff7a1a, alpha: 0.22 });
// Logs
gfx.roundRect(cx - 9, cy + 1, 18, 3, 1.5); gfx.roundRect(cx - 9, cy + 1, 18, 3, 1.5);
gfx.fill({ color: 0x5a3a24, alpha: 0.95 }); gfx.fill({ color: 0x5a3a24, alpha: 0.95 });
gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5); gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5);
gfx.fill({ color: 0x6b4428, alpha: 0.9 }); gfx.fill({ color: 0x6b4428, alpha: 0.9 });
// Flame (layered circles for lightweight VFX)
const pulse = 0.9 + 0.2 * Math.sin(now * 0.012); const pulse = 0.9 + 0.2 * Math.sin(now * 0.012);
gfx.circle(cx, cy - 6, 5.2 * pulse); gfx.circle(cx, cy - 6, 5.2 * pulse);
gfx.fill({ color: 0xff8a2a, alpha: 0.8 }); gfx.fill({ color: 0xff8a2a, alpha: 0.8 });
@ -592,11 +621,11 @@ export class GameRenderer {
gfx.circle(cx, cy - 8, 1.6 * pulse); gfx.circle(cx, cy - 8, 1.6 * pulse);
gfx.fill({ color: 0xfff3b0, alpha: 0.95 }); gfx.fill({ color: 0xfff3b0, alpha: 0.95 });
gfx.zIndex = cy + 96; gfx.zIndex = Math.max(ty, cy) + 94;
} }
clearCampfire(): void { clearRestCamp(): void {
if (this._campfireGfx) this._campfireGfx.clear(); if (this._restCampGfx) this._restCampGfx.clear();
} }
/** /**

@ -120,6 +120,8 @@ export interface HeroState {
serverActivityState?: string; serverActivityState?: string;
/** Server rest flavor: "town" */ /** Server rest flavor: "town" */
restKind?: string; restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string;
attackSpeed: number; attackSpeed: number;
damage: number; damage: number;
defense: number; defense: number;

@ -108,6 +108,7 @@ export interface HeroResponse {
luck: number; luck: number;
state: string; state: string;
restKind?: string; restKind?: string;
excursionPhase?: string;
weaponId: number; weaponId: number;
armorId: number; armorId: number;
weapon: WeaponResponse | null; weapon: WeaponResponse | null;

Loading…
Cancel
Save