diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index d22d187..3ee4a6b 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -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}) } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 421662b..253b153 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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 diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 416386d..dda6c2e 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -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:"-"` diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index ac6c6ea..b96b212 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42956a8..9372bc7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 626d55a..c93b95e 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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 diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 2eb51ae..f7e0586 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -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(); } /** diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 30209e0..3e39d87 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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; diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 4252787..a068dc6 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -108,6 +108,7 @@ export interface HeroResponse { luck: number; state: string; restKind?: string; + excursionPhase?: string; weaponId: number; armorId: number; weapon: WeaponResponse | null;