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 {
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()
sender := e.sender
for heroID, hm := range e.movements {
if hm.State != model.StateWalking {
if hm == nil {
continue
}
if sender != nil {
if sender != nil && hm.State == model.StateWalking {
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()
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
// instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
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.
@ -925,9 +945,53 @@ func (hm *HeroMovement) SyncToHero() {
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()
}
// 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 {
var p *model.TownPausePersisted
@ -1241,6 +1305,35 @@ func clamp01(v float64) float64 {
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 {
if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 {
return false
@ -1412,7 +1505,8 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, ene
if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false
}
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus
wildness := hm.excursionWildness(now)
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness
merchantW := cfg.MerchantEncounterWeightBase
total := monsterW + merchantW
r := rand.Float64() * total

@ -65,6 +65,8 @@ type Hero struct {
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"`
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 *TownPausePersisted `json:"-"`

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

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

@ -777,7 +777,18 @@ export class GameEngine {
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
if (this._thoughtText) {
@ -792,7 +803,7 @@ export class GameEngine {
this.renderer.clearThoughtBubble();
}
} else {
this.renderer.clearCampfire();
this.renderer.clearRestCamp();
}
// Draw NPCs from towns

@ -77,7 +77,8 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: 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 _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null;
@ -317,8 +318,8 @@ export class GameRenderer {
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
this._campfireGfx = new Graphics();
this.entityLayer.addChild(this._campfireGfx);
this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._restCampGfx);
this._enemyGfx = new Graphics();
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 {
const gfx = this._campfireGfx;
/**
* Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
* 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;
gfx.clear();
const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.005) * 1.2;
const cx = iso.x + 18;
const cy = iso.y + 9 + bob;
const bob = Math.sin(now * 0.004) * 1.0;
// --- 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.fill({ color: 0x000000, alpha: 0.22 });
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.fill({ color: 0x5a3a24, alpha: 0.95 });
gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5);
gfx.fill({ color: 0x6b4428, alpha: 0.9 });
// Flame (layered circles for lightweight VFX)
const pulse = 0.9 + 0.2 * Math.sin(now * 0.012);
gfx.circle(cx, cy - 6, 5.2 * pulse);
gfx.fill({ color: 0xff8a2a, alpha: 0.8 });
@ -592,11 +621,11 @@ export class GameRenderer {
gfx.circle(cx, cy - 8, 1.6 * pulse);
gfx.fill({ color: 0xfff3b0, alpha: 0.95 });
gfx.zIndex = cy + 96;
gfx.zIndex = Math.max(ty, cy) + 94;
}
clearCampfire(): void {
if (this._campfireGfx) this._campfireGfx.clear();
clearRestCamp(): void {
if (this._restCampGfx) this._restCampGfx.clear();
}
/**

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

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

Loading…
Cancel
Save