refactor graph

master
Denis Ranneft 1 month ago
parent 8ecaf3895a
commit 016cb41263

@ -388,6 +388,25 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
hero.HP = hero.MaxHP hero.HP = hero.MaxHP
} }
hm.SyncToHero()
// Keep combat state's hero pointer aligned with movement (authoritative live hero).
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hm.Hero
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
e.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
}
}
if e.adventureLog != nil {
e.adventureLog(msg.HeroID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
}
// Emit as an attack-like event so the client shows it. // Emit as an attack-like event so the client shows it.
cs, hasCombat := e.combats[msg.HeroID] cs, hasCombat := e.combats[msg.HeroID]
enemyHP := 0 enemyHP := 0
@ -402,6 +421,9 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
HeroHP: hero.HP, HeroHP: hero.HP,
EnemyHP: enemyHP, EnemyHP: enemyHP,
}) })
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now())
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
} }
} }
@ -628,6 +650,36 @@ func (e *Engine) ApplyAdminStartAdventure(heroID int64) (*model.Hero, bool) {
return h, true return h, true
} }
// ApplyAdminStopAdventure ends the deep-wild phase immediately: hero animates back to the road, then keeps walking.
func (e *Engine) ApplyAdminStopAdventure(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok || e.roadGraph == nil {
return nil, false
}
now := time.Now()
if !hm.ForceAdventureReturnToRoad(now) {
return nil, false
}
hm.SyncToHero()
h := hm.Hero
if e.sender != nil {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
e.sender.SendToHero(heroID, "hero_state", h)
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
e.logger.Error("persist hero after stop adventure", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival). // ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival).
func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()
@ -649,10 +701,8 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_state", h)
town := e.roadGraph.Towns[hm.CurrentTownID] town := e.roadGraph.Towns[hm.CurrentTownID]
if town != nil { if town != nil {
npcInfos := make([]model.TownNPCInfo, 0, len(e.roadGraph.TownNPCs[hm.CurrentTownID])) npcInfos := e.roadGraph.TownNPCInfos(hm.CurrentTownID)
for _, n := range e.roadGraph.TownNPCs[hm.CurrentTownID] { buildingInfos := e.roadGraph.TownBuildingInfos(hm.CurrentTownID)
npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type})
}
var restMs int64 var restMs int64
if hm.State == model.StateResting { if hm.State == model.StateResting {
restMs = hm.RestUntil.Sub(now).Milliseconds() restMs = hm.RestUntil.Sub(now).Milliseconds()
@ -662,6 +712,7 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
TownName: town.Name, TownName: town.Name,
Biome: town.Biome, Biome: town.Biome,
NPCs: npcInfos, NPCs: npcInfos,
Buildings: buildingInfos,
RestDurationMs: restMs, RestDurationMs: restMs,
}) })
} }
@ -845,6 +896,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
// Update movement state. // Update movement state.
if hm, ok := e.movements[hero.ID]; ok { if hm, ok := e.movements[hero.ID]; ok {
hm.StartFighting() hm.StartFighting()
hm.SyncToHero()
} }
heap.Push(&e.queue, &model.AttackEvent{ heap.Push(&e.queue, &model.AttackEvent{
@ -1226,6 +1278,25 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
leveledUp := hero.Level > oldLevel leveledUp := hero.Level > oldLevel
delete(e.combats, cs.HeroID)
// Resume walking before hero_state so positions match hero_move (road + forest offset).
if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now)
hm.SyncToHero()
}
// Persist progression (XP, gold, level/stats after level-up, inventory, world state)
// so a disconnect or crash does not roll back combat rewards.
if e.heroStore != nil && hero != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := e.heroStore.Save(ctx, hero)
cancel()
if err != nil && e.logger != nil {
e.logger.Error("persist hero after combat victory", "hero_id", hero.ID, "error", err)
}
}
// Push typed combat_end envelope. // Push typed combat_end envelope.
if e.sender != nil { if e.sender != nil {
e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{ e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
@ -1246,13 +1317,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
e.sender.SendToHero(cs.HeroID, "hero_state", hero) e.sender.SendToHero(cs.HeroID, "hero_state", hero)
} }
delete(e.combats, cs.HeroID)
// Resume walking.
if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now)
}
e.logger.Info("enemy defeated", e.logger.Info("enemy defeated",
"hero_id", cs.HeroID, "hero_id", cs.HeroID,
"enemy", enemy.Name, "enemy", enemy.Name,
@ -1290,19 +1354,41 @@ func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) {
} }
} }
// processPositionSync sends drift-correction position_sync messages. // processPositionSync sends drift-correction position_sync messages and persists world (x,y).
// Called at 0.1 Hz (every 10s). // Called at low cadence (see tuning positionSyncRateMs).
func (e *Engine) processPositionSync(now time.Time) { func (e *Engine) processPositionSync(now time.Time) {
type posSnap struct {
id int64
x float64
y float64
}
var snaps []posSnap
e.mu.RLock() e.mu.RLock()
defer e.mu.RUnlock() sender := e.sender
for heroID, hm := range e.movements {
if hm.State != model.StateWalking {
continue
}
if sender != nil {
sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
}
if hm.Hero != nil {
hm.SyncToHero()
snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY})
}
}
heroStore := e.heroStore
e.mu.RUnlock()
if e.sender == nil { if heroStore == nil || len(snaps) == 0 {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
for heroID, hm := range e.movements { defer cancel()
if hm.State == model.StateWalking { for _, p := range snaps {
e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) if err := heroStore.SavePosition(ctx, p.id, p.x, p.y); err != nil && e.logger != nil {
e.logger.Error("position sync persist failed", "hero_id", p.id, "error", err)
} }
} }
} }

@ -76,10 +76,13 @@ type HeroMovement struct {
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
TownLeaveAt time.Time TownLeaveAt time.Time
// Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends. // Off-road excursion ("looking for trouble"): timers not persisted; cleared on town enter and when it ends.
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
// AdventureWanderX/Y: small display-only random drift while adventuring (reset when adventure ends).
AdventureWanderX float64
AdventureWanderY float64
// Roadside rest (low HP): unified under StateResting with a roadside flag; persisted in heroes.town_pause. // Roadside rest (low HP): unified under StateResting with a roadside flag; persisted in heroes.town_pause.
// RoadsideRestActive indicates "resting on roadside" flavor inside the unified resting state. // RoadsideRestActive indicates "resting on roadside" flavor inside the unified resting state.
@ -236,14 +239,16 @@ func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) {
} }
} }
// crossRoadChance is the probability of picking a cross-road instead of following the ring.
const crossRoadChance = 0.3
// pickDestination selects the next town the hero should walk toward. // pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough. // Only towns connected by a roads row are chosen — TownOrder alone is not enough.
// When multiple outgoing roads exist, there's a chance the hero takes a cross-road.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) { func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
defer hm.avoidSelfLoopDestination(graph) defer hm.avoidSelfLoopDestination(graph)
if hm.CurrentTownID == 0 { if hm.CurrentTownID == 0 {
// Fresh heroes are inserted at (0,0). NearestTown(0,0) is often the wrong ring vertex;
// TownOrder[0] is lowest level_min (progression start), matching narrative and ring exits.
if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 { if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 {
hm.CurrentTownID = graph.TownOrder[0] hm.CurrentTownID = graph.TownOrder[0]
} else { } else {
@ -277,6 +282,16 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
return return
} }
// When multiple roads are available, sometimes take a cross-road for variety.
outgoing := graph.TownRoads[hm.CurrentTownID]
if len(outgoing) > 2 && rand.Float64() < crossRoadChance {
pick := outgoing[rand.Intn(len(outgoing))]
if pick != nil && pick.ToTownID != hm.CurrentTownID {
hm.DestinationTownID = pick.ToTownID
return
}
}
if dest := hm.firstReachableOnRing(graph, idx); dest != 0 { if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
hm.DestinationTownID = dest hm.DestinationTownID = dest
return return
@ -446,6 +461,21 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
hm.refreshSpeed(now) hm.refreshSpeed(now)
distThisTick := hm.Speed * dt distThisTick := hm.Speed * dt
var wAdv float64
if hm.adventureActive(now) {
wAdv = hm.wildernessFactor(now)
cfg := tuning.Get()
frac := cfg.AdventureForwardSpeedWildFraction
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
// w=0: full road speed; w=1: frac of road speed (exploring, not rushing to town).
distThisTick *= (1-wAdv) + wAdv*frac
}
for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
from := hm.Road.Waypoints[hm.WaypointIndex] from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1] to := hm.Road.Waypoints[hm.WaypointIndex+1]
@ -529,6 +559,8 @@ func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
hm.AdventureStartAt = time.Time{} hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{} hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0 hm.AdventureSide = 0
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
} }
func (hm *HeroMovement) roadsideRestInProgress() bool { func (hm *HeroMovement) roadsideRestInProgress() bool {
@ -559,11 +591,9 @@ func (hm *HeroMovement) EndRoadsideRest() {
hm.endRoadsideRest() hm.endRoadsideRest()
} }
// beginRoadsideRestSession starts a roadside session until endAt. Clears adventure excursion. // beginRoadsideRestSession starts a roadside session until endAt. Does not clear an active adventure timer
// so low-HP pull-over during a mini-adventure resumes the same excursion after rest.
func (hm *HeroMovement) beginRoadsideRestSession(now, endAt time.Time) { func (hm *HeroMovement) beginRoadsideRestSession(now, endAt time.Time) {
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
hm.RoadsideRestActive = true hm.RoadsideRestActive = true
hm.RoadsideRestEndAt = endAt hm.RoadsideRestEndAt = endAt
hm.RoadsideRestStartedAt = now hm.RoadsideRestStartedAt = now
@ -616,7 +646,7 @@ func (hm *HeroMovement) applyTownRestHeal(dt float64) {
} }
} }
// tryStartRoadsideRest pulls the hero off the road when HP is low; cancels an active adventure. // tryStartRoadsideRest pulls the hero off the road when HP is low; an active adventure timer keeps running.
func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) { func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) {
if hm.roadsideRestInProgress() { if hm.roadsideRestInProgress() {
return return
@ -681,6 +711,8 @@ func (hm *HeroMovement) tryStartAdventure(now time.Time) {
spanNs = 1 spanNs = 1
} }
hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1))) hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1)))
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
hm.AdventureSide = 1 hm.AdventureSide = 1
} else { } else {
@ -711,6 +743,8 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
} }
hm.AdventureStartAt = now hm.AdventureStartAt = now
hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1))) hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1)))
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
hm.AdventureSide = 1 hm.AdventureSide = 1
} else { } else {
@ -719,6 +753,21 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
return true return true
} }
// ForceAdventureReturnToRoad snaps the adventure to the outward walk-back leg (same return duration as roadside rest).
func (hm *HeroMovement) ForceAdventureReturnToRoad(now time.Time) bool {
if !hm.adventureActive(now) {
return false
}
cfg := tuning.Get()
dtIn := time.Duration(cfg.RoadsideRestGoInMs) * time.Millisecond
dtOut := time.Duration(cfg.RoadsideRestReturnMs) * time.Millisecond
total := dtIn + dtOut
dtIn2, dtOut2 := roadsideRestPhaseDurations(total)
hm.AdventureEndAt = now.Add(dtOut2)
hm.AdventureStartAt = now.Add(-dtIn2)
return true
}
// AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest). // AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest).
func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error {
if graph == nil || townID == 0 { if graph == nil || townID == 0 {
@ -735,6 +784,8 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
hm.AdventureStartAt = time.Time{} hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{} hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0 hm.AdventureSide = 0
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
hm.endRoadsideRest() hm.endRoadsideRest()
hm.WanderingMerchantDeadline = time.Time{} hm.WanderingMerchantDeadline = time.Time{}
hm.TownVisitNPCName = "" hm.TownVisitNPCName = ""
@ -761,6 +812,8 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm.AdventureStartAt = time.Time{} hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{} hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0 hm.AdventureSide = 0
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
hm.WanderingMerchantDeadline = time.Time{} hm.WanderingMerchantDeadline = time.Time{}
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
@ -806,39 +859,76 @@ func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
return true return true
} }
// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back. // adventureDepthFactor is 0 on the road, then smoothsteps in (RoadsideRestGoInMs), holds, then out before AdventureEndAt.
// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.) // Same timing and depth scale as roadside rest so "looking for trouble" pulls off the road as visibly as pull-over rest.
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { func (hm *HeroMovement) adventureDepthFactor(now time.Time) float64 {
if !hm.adventureActive(now) { if !hm.adventureActive(now) {
return 0 return 0
} }
total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds() t0 := hm.AdventureStartAt
if total <= 0 { tEnd := hm.AdventureEndAt
if tEnd.IsZero() {
return 0 return 0
} }
elapsed := now.Sub(hm.AdventureStartAt).Seconds() if !now.Before(tEnd) {
p := elapsed / total return 0
if p < 0 {
p = 0
} else if p > 1 {
p = 1
} }
r := tuning.Get().AdventureWildernessRampFraction if t0.IsZero() {
if r < 1e-6 { t0 = tEnd.Add(-365 * 24 * time.Hour)
r = 1e-6
} }
if r > 0.49 { total := tEnd.Sub(t0)
r = 0.49 if total <= 0 {
return 1
} }
if p < r { dtIn, dtOut := roadsideRestPhaseDurations(total)
return p / r if now.Before(t0) {
return 0
} }
if p > 1-r { if dtIn > 0 && now.Before(t0.Add(dtIn)) {
return (1 - p) / r e := float64(now.Sub(t0)) / float64(dtIn)
return smoothstep01(e)
}
if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) {
e := float64(tEnd.Sub(now)) / float64(dtOut)
return smoothstep01(e)
} }
return 1 return 1
} }
// wildernessFactor matches adventureDepthFactor while an adventure is active (encounters, forward speed).
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
return hm.adventureDepthFactor(now)
}
// stepAdventureWander applies a small bounded random drift in world space while off-road (display feel).
func (hm *HeroMovement) stepAdventureWander(now time.Time, dt float64) {
if !hm.adventureActive(now) || dt <= 0 || hm.State != model.StateWalking {
return
}
w := hm.wildernessFactor(now)
if w <= 0 {
return
}
cfg := tuning.Get()
twitch := cfg.AdventureWanderSpeedRatio
if twitch <= 0 {
twitch = tuning.DefaultValues().AdventureWanderSpeedRatio
}
step := hm.Speed * twitch * w * dt
hm.AdventureWanderX += (rand.Float64()*2 - 1) * step
hm.AdventureWanderY += (rand.Float64()*2 - 1) * step
maxR := cfg.AdventureWanderMaxRadius
if maxR <= 0 {
maxR = tuning.DefaultValues().AdventureWanderMaxRadius
}
r := math.Hypot(hm.AdventureWanderX, hm.AdventureWanderY)
if r > maxR && r > 1e-9 {
s := maxR / r
hm.AdventureWanderX *= s
hm.AdventureWanderY *= s
}
}
func smoothstep01(t float64) float64 { func smoothstep01(t float64) float64 {
if t <= 0 { if t <= 0 {
return 0 return 0
@ -943,6 +1033,17 @@ func roadsideRestDepthWorldUnits() float64 {
return cfg.RoadsideRestLateral return cfg.RoadsideRestLateral
} }
// adventureWildDepthWorldUnits is max perpendicular reach at full adventure depth: same base as roadside camp,
// scaled further into the wild (AdventureWildDepthScale).
func adventureWildDepthWorldUnits() float64 {
cfg := tuning.Get()
scale := cfg.AdventureWildDepthScale
if scale <= 0 {
scale = tuning.DefaultValues().AdventureWildDepthScale
}
return roadsideRestDepthWorldUnits() * scale
}
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 { if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1 return 0, 1
@ -965,6 +1066,29 @@ func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
return -dy / L, dx / L return -dy / L, dx / L
} }
// roadForwardUnit is the normalized tangent along the road toward the next waypoint.
func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 1, 0
}
idx := hm.WaypointIndex
if idx >= len(hm.Road.Waypoints)-1 {
idx = len(hm.Road.Waypoints) - 2
}
if idx < 0 {
return 1, 0
}
from := hm.Road.Waypoints[idx]
to := hm.Road.Waypoints[idx+1]
dx := to.X - from.X
dy := to.Y - from.Y
L := math.Hypot(dx, dy)
if L < 1e-6 {
return 1, 0
}
return dx / L, dy / L
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
if hm.roadsideRestInProgress() { if hm.roadsideRestInProgress() {
if hm.RoadsideRestSide == 0 { if hm.RoadsideRestSide == 0 {
@ -975,13 +1099,20 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f
return px * mag, py * mag return px * mag, py * mag
} }
w := hm.wildernessFactor(now) if hm.adventureActive(now) && hm.AdventureSide != 0 && hm.Road != nil && len(hm.Road.Waypoints) >= 2 {
if w <= 0 || hm.AdventureSide == 0 { f := hm.adventureDepthFactor(now)
return 0, 0 depth := adventureWildDepthWorldUnits()
cfg := tuning.Get()
if cfg.AdventureWildLateralMax > 0 {
if alt := cfg.AdventureWildLateralMax; alt > depth {
depth = alt
}
} }
px, py := hm.roadPerpendicularUnit() px, py := hm.roadPerpendicularUnit()
mag := float64(hm.AdventureSide) * tuning.Get().AdventureMaxLateral * w mag := float64(hm.AdventureSide) * depth * f
return px * mag, py * mag return px*mag + hm.AdventureWanderX, py*mag + hm.AdventureWanderY
}
return 0, 0
} }
// WanderingMerchantCost matches REST encounter / npc alms pricing. // WanderingMerchantCost matches REST encounter / npc alms pricing.
@ -1035,6 +1166,8 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.AdventureStartAt = time.Time{} hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{} hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0 hm.AdventureSide = 0
hm.AdventureWanderX = 0
hm.AdventureWanderY = 0
hm.endRoadsideRest() hm.endRoadsideRest()
ids := graph.TownNPCIDs(destID) ids := graph.TownNPCIDs(destID)
@ -1108,9 +1241,12 @@ func (hm *HeroMovement) Die() {
} }
// SyncToHero writes movement state back to the hero model for persistence. // SyncToHero writes movement state back to the hero model for persistence.
// Position uses the same world coordinates as hero_move / position_sync (road spine + display offset).
func (hm *HeroMovement) SyncToHero() { func (hm *HeroMovement) SyncToHero() {
hm.Hero.PositionX = hm.CurrentX now := time.Now()
hm.Hero.PositionY = hm.CurrentY ox, oy := hm.displayOffset(now)
hm.Hero.PositionX = hm.CurrentX + ox
hm.Hero.PositionY = hm.CurrentY + oy
hm.Hero.State = hm.State hm.Hero.State = hm.State
if hm.CurrentTownID != 0 { if hm.CurrentTownID != 0 {
id := hm.CurrentTownID id := hm.CurrentTownID
@ -1438,6 +1574,7 @@ func ProcessSingleHeroMovementTick(
return return
case model.StateResting: case model.StateResting:
hm.expireAdventureIfNeeded(now)
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport). // Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
dt := now.Sub(hm.LastMoveTick).Seconds() dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 { if dt <= 0 {
@ -1574,8 +1711,7 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
} }
if hm.Hero != nil { if hm.Hero != nil {
hm.Hero.PositionX = hm.CurrentX hm.SyncToHero()
hm.Hero.PositionY = hm.CurrentY
} }
return return
} }
@ -1594,7 +1730,14 @@ func ProcessSingleHeroMovementTick(
} }
hm.tryStartAdventure(now) hm.tryStartAdventure(now)
dtMove := now.Sub(hm.LastMoveTick).Seconds()
if dtMove <= 0 {
dtMove = movementTickRate().Seconds()
}
reachedTown := hm.AdvanceTick(now, graph) reachedTown := hm.AdvanceTick(now, graph)
if !reachedTown {
hm.stepAdventureWander(now, dtMove)
}
if reachedTown { if reachedTown {
hm.EnterTown(now, graph) hm.EnterTown(now, graph)
@ -1602,10 +1745,8 @@ func ProcessSingleHeroMovementTick(
if sender != nil { if sender != nil {
town := graph.Towns[hm.CurrentTownID] town := graph.Towns[hm.CurrentTownID]
if town != nil { if town != nil {
npcInfos := make([]model.TownNPCInfo, 0, len(graph.TownNPCs[hm.CurrentTownID])) npcInfos := graph.TownNPCInfos(hm.CurrentTownID)
for _, n := range graph.TownNPCs[hm.CurrentTownID] { buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID)
npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type})
}
var restMs int64 var restMs int64
if hm.State == model.StateResting { if hm.State == model.StateResting {
restMs = hm.RestUntil.Sub(now).Milliseconds() restMs = hm.RestUntil.Sub(now).Milliseconds()
@ -1615,6 +1756,7 @@ func ProcessSingleHeroMovementTick(
TownName: town.Name, TownName: town.Name,
Biome: town.Biome, Biome: town.Biome,
NPCs: npcInfos, NPCs: npcInfos,
Buildings: buildingInfos,
RestDurationMs: restMs, RestDurationMs: restMs,
}) })
} }
@ -1664,7 +1806,6 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
} }
hm.Hero.PositionX = hm.CurrentX hm.SyncToHero()
hm.Hero.PositionY = hm.CurrentY
} }
} }

@ -31,6 +31,19 @@ type TownNPC struct {
ID int64 ID int64
Name string Name string
Type string Type string
BuildingID *int64
}
// TownBuilding is a building placed in a town (from town_buildings table).
type TownBuilding struct {
ID int64
TownID int64
BuildingType string
OffsetX float64
OffsetY float64
Facing string
FootprintW float64
FootprintH float64
} }
// RoadGraph is an immutable in-memory graph of all roads and towns, // RoadGraph is an immutable in-memory graph of all roads and towns,
@ -42,6 +55,7 @@ type RoadGraph struct {
TownOrder []int64 // ordered town IDs for sequential traversal TownOrder []int64 // ordered town IDs for sequential traversal
TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order) TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order)
NPCByID map[int64]TownNPC // NPC id -> row NPCByID map[int64]TownNPC // NPC id -> row
TownBuildings map[int64][]TownBuilding // town ID -> buildings
} }
// LoadRoadGraph reads roads and towns from the database, generates waypoints // LoadRoadGraph reads roads and towns from the database, generates waypoints
@ -53,6 +67,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
Towns: make(map[int64]*model.Town), Towns: make(map[int64]*model.Town),
TownNPCs: make(map[int64][]TownNPC), TownNPCs: make(map[int64][]TownNPC),
NPCByID: make(map[int64]TownNPC), NPCByID: make(map[int64]TownNPC),
TownBuildings: make(map[int64][]TownBuilding),
} }
// Load towns. // Load towns.
@ -74,7 +89,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate towns: %w", err) return nil, fmt.Errorf("iterate towns: %w", err)
} }
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type FROM npcs ORDER BY town_id, id`) npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id FROM npcs ORDER BY town_id, id`)
if err != nil { if err != nil {
return nil, fmt.Errorf("load npcs: %w", err) return nil, fmt.Errorf("load npcs: %w", err)
} }
@ -82,7 +97,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for npcRows.Next() { for npcRows.Next() {
var n TownNPC var n TownNPC
var townID int64 var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type); err != nil { if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
g.NPCByID[n.ID] = n g.NPCByID[n.ID] = n
@ -92,6 +107,23 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate npcs: %w", err) return nil, fmt.Errorf("iterate npcs: %w", err)
} }
// Load buildings.
buildingRows, err := pool.Query(ctx, `SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h FROM town_buildings ORDER BY town_id, id`)
if err != nil {
return nil, fmt.Errorf("load buildings: %w", err)
}
defer buildingRows.Close()
for buildingRows.Next() {
var b TownBuilding
if err := buildingRows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
g.TownBuildings[b.TownID] = append(g.TownBuildings[b.TownID], b)
}
if err := buildingRows.Err(); err != nil {
return nil, fmt.Errorf("iterate buildings: %w", err)
}
// Load roads. // Load roads.
roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`) roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`)
if err != nil { if err != nil {
@ -126,6 +158,43 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return g, nil return g, nil
} }
// TownBuildingInfos returns building payloads for the given town, with absolute world coordinates.
func (g *RoadGraph) TownBuildingInfos(townID int64) []model.TownBuildingInfo {
town := g.Towns[townID]
if town == nil {
return nil
}
buildings := g.TownBuildings[townID]
infos := make([]model.TownBuildingInfo, 0, len(buildings))
for _, b := range buildings {
infos = append(infos, model.TownBuildingInfo{
ID: b.ID,
BuildingType: b.BuildingType,
WorldX: town.WorldX + b.OffsetX,
WorldY: town.WorldY + b.OffsetY,
Facing: b.Facing,
FootprintW: b.FootprintW,
FootprintH: b.FootprintH,
})
}
return infos
}
// TownNPCInfos returns NPC payloads for the given town, including building IDs.
func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo {
npcs := g.TownNPCs[townID]
infos := make([]model.TownNPCInfo, 0, len(npcs))
for _, n := range npcs {
infos = append(infos, model.TownNPCInfo{
ID: n.ID,
Name: n.Name,
Type: n.Type,
BuildingID: n.BuildingID,
})
}
return infos
}
// TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues). // TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues).
func (g *RoadGraph) TownNPCIDs(townID int64) []int64 { func (g *RoadGraph) TownNPCIDs(townID int64) []int64 {
list := g.TownNPCs[townID] list := g.TownNPCs[townID]

@ -62,6 +62,92 @@ type heroSummary struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
AdventureActive bool `json:"adventureActive"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
AdventureStartedAt *time.Time `json:"adventureStartedAt,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
RoadsideRestActive bool `json:"roadsideRestActive"`
RoadsideRestEndAt *time.Time `json:"roadsideRestEndAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
}
// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
type adminHeroDetailResponse struct {
model.Hero
TownPause *model.TownPausePersisted `json:"townPause,omitempty"`
AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"`
}
func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLiveMovementJSON {
if hm == nil {
return nil
}
s := &adminLiveMovementJSON{
Online: true,
MoveState: string(hm.State),
RoadsideRestActive: hm.RoadsideRestActive,
}
if !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt) {
s.AdventureActive = true
end := hm.AdventureEndAt
s.AdventureEndsAt = &end
st := hm.AdventureStartAt
s.AdventureStartedAt = &st
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
s.RestUntil = &t
}
if !hm.TownLeaveAt.IsZero() {
t := hm.TownLeaveAt
s.TownLeaveAt = &t
}
if !hm.NextTownNPCRollAt.IsZero() {
t := hm.NextTownNPCRollAt
s.NextTownNPCRollAt = &t
}
if !hm.RoadsideRestEndAt.IsZero() {
t := hm.RoadsideRestEndAt
s.RoadsideRestEndAt = &t
}
if hm.CurrentTownID != 0 {
s.CurrentTownID = hm.CurrentTownID
}
if hm.DestinationTownID != 0 {
s.DestinationTownID = hm.DestinationTownID
}
if !hm.WanderingMerchantDeadline.IsZero() {
t := hm.WanderingMerchantDeadline
s.WanderingMerchantDeadline = &t
}
return s
}
func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) {
if hero == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "nil hero"})
return
}
now := time.Now()
hero.RefreshDerivedCombatStats(now)
out := adminHeroDetailResponse{Hero: *hero, TownPause: hero.TownPause}
if hm := h.engine.GetMovements(hero.ID); hm != nil && hm.Hero != nil {
out.Hero = *hm.Hero
out.Hero.RefreshDerivedCombatStats(now)
out.TownPause = hm.Hero.TownPause
out.AdminLiveMovement = buildAdminLiveMovementSnap(hm, now)
}
writeJSON(w, http.StatusOK, out)
}
// ListHeroes returns a paginated list of all heroes. // ListHeroes returns a paginated list of all heroes.
// GET /admin/heroes?limit=20&offset=0 // GET /admin/heroes?limit=20&offset=0
func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) {
@ -749,13 +835,7 @@ func (h *AdminHandler) GetHero(w http.ResponseWriter, r *http.Request) {
return return
} }
hero.RefreshDerivedCombatStats(time.Now()) h.writeAdminHeroDetail(w, hero)
// Prefer live movement hero when online; otherwise return DB hero (GetMovements is nil offline).
if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
writeJSON(w, http.StatusOK, hm.Hero)
return
}
writeJSON(w, http.StatusOK, hero)
} }
type setLevelRequest struct { type setLevelRequest struct {
@ -1306,8 +1386,6 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request
}) })
return return
} }
var hm = h.engine.GetMovements(heroID)
hero = hm.Hero
if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{ writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "hero must be alive and not in combat", "error": "hero must be alive and not in combat",
@ -1331,9 +1409,8 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request
}) })
return return
} }
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start adventure", "hero_id", heroID) h.logger.Info("admin: start adventure", "hero_id", heroID)
writeJSON(w, http.StatusOK, out) h.writeAdminHeroDetail(w, out)
return return
} }
@ -1348,7 +1425,70 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request
return return
} }
h.logger.Info("admin: start adventure (offline)", "hero_id", heroID) h.logger.Info("admin: start adventure (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2) h.writeAdminHeroDetail(w, hero2)
}
// StopHeroAdventure forces the return-to-road phase of an active mini-adventure (online or offline).
// POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroAdventure(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for stop-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStopAdventure(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "no active adventure",
})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: stop adventure", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
if !hm.ForceAdventureReturnToRoad(now) {
return fmt.Errorf("no active adventure")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: stop adventure (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
} }
// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest). // TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest).
@ -1483,9 +1623,8 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start rest", "hero_id", heroID) h.logger.Info("admin: start rest", "hero_id", heroID)
writeJSON(w, http.StatusOK, out) h.writeAdminHeroDetail(w, out)
return return
} }
@ -1500,7 +1639,7 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
return return
} }
h.logger.Info("admin: start rest (offline)", "hero_id", heroID) h.logger.Info("admin: start rest (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2) h.writeAdminHeroDetail(w, hero2)
} }
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online. // ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
@ -1722,63 +1861,6 @@ func (h *AdminHandler) StopRoadsideRest(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, hero2) writeJSON(w, http.StatusOK, hero2)
} }
// StopHeroRoadsideRest ends only roadside pull-over rest (live movement session).
// Does not end town / inn rest (use stop-rest or leave-town). POST /admin/heroes/{heroId}/stop-roadside-rest
func (h *AdminHandler) StopHeroRoadsideRest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, heroErr := h.store.GetByID(r.Context(), heroID)
if heroErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID)
if out == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "movement session unavailable",
})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest only", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
_ = rg
_ = now
hm.EndRoadsideRest()
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
hero2.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest only (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
}
// PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls. // PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls.
// POST /admin/time/pause // POST /admin/time/pause
func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) {

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
) )
@ -65,6 +66,57 @@ func (h *QuestHandler) ListNPCsByTown(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, npcs) writeJSON(w, http.StatusOK, npcs)
} }
// ListBuildingsByTown returns all buildings in a town.
// GET /api/v1/towns/{townId}/buildings
func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Request) {
townIDStr := chi.URLParam(r, "townId")
townID, err := strconv.ParseInt(townIDStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid townId",
})
return
}
town, err := h.questStore.GetTown(r.Context(), townID)
if err != nil {
h.logger.Error("failed to get town for buildings", "town_id", townID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load town",
})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "town not found",
})
return
}
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
if err != nil {
h.logger.Error("failed to list buildings", "town_id", townID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to list buildings",
})
return
}
views := make([]model.BuildingView, 0, len(buildings))
for _, b := range buildings {
views = append(views, model.BuildingView{
ID: b.ID,
BuildingType: b.BuildingType,
WorldX: town.WorldX + b.OffsetX,
WorldY: town.WorldY + b.OffsetY,
Facing: b.Facing,
FootprintW: b.FootprintW,
FootprintH: b.FootprintH,
})
}
writeJSON(w, http.StatusOK, views)
}
// ListQuestsByNPC returns all quests offered by an NPC. // ListQuestsByNPC returns all quests offered by an NPC.
// GET /api/v1/npcs/{npcId}/quests // GET /api/v1/npcs/{npcId}/quests
func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {

@ -22,6 +22,7 @@ type NPC struct {
Type string `json:"type"` // quest_giver, merchant, healer Type string `json:"type"` // quest_giver, merchant, healer
OffsetX float64 `json:"offsetX"` OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"` OffsetY float64 `json:"offsetY"`
BuildingID *int64 `json:"buildingId,omitempty"`
} }
// Quest is a template definition offered by a quest-giver NPC. // Quest is a template definition offered by a quest-giver NPC.
@ -62,7 +63,7 @@ type QuestReward struct {
Potions int `json:"potions"` Potions int `json:"potions"`
} }
// TownWithNPCs is a Town annotated with its NPC residents and computed world positions. // TownWithNPCs is a Town annotated with its NPC residents, buildings and computed world positions.
type TownWithNPCs struct { type TownWithNPCs struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -72,6 +73,7 @@ type TownWithNPCs struct {
Radius float64 `json:"radius"` Radius float64 `json:"radius"`
Size string `json:"size"` // S, M, L derived from radius Size string `json:"size"` // S, M, L derived from radius
NPCs []NPCView `json:"npcs"` NPCs []NPCView `json:"npcs"`
Buildings []BuildingView `json:"buildings"`
} }
// NPCView is the frontend-friendly view of an NPC with absolute world coordinates. // NPCView is the frontend-friendly view of an NPC with absolute world coordinates.
@ -81,6 +83,7 @@ type NPCView struct {
Type string `json:"type"` Type string `json:"type"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
BuildingID *int64 `json:"buildingId,omitempty"`
} }
// TownSizeFromRadius derives a size label from the town radius. // TownSizeFromRadius derives a size label from the town radius.

@ -119,6 +119,18 @@ type TownNPCInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
BuildingID *int64 `json:"buildingId,omitempty"`
}
// TownBuildingInfo describes a building in a town (town_enter payload).
type TownBuildingInfo struct {
ID int64 `json:"id"`
BuildingType string `json:"buildingType"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
Facing string `json:"facing"`
FootprintW float64 `json:"footprintW"`
FootprintH float64 `json:"footprintH"`
} }
// TownEnterPayload is sent when a hero arrives at a town. // TownEnterPayload is sent when a hero arrives at a town.
@ -127,6 +139,7 @@ type TownEnterPayload struct {
TownName string `json:"townName"` TownName string `json:"townName"`
Biome string `json:"biome"` Biome string `json:"biome"`
NPCs []TownNPCInfo `json:"npcs"` NPCs []TownNPCInfo `json:"npcs"`
Buildings []TownBuildingInfo `json:"buildings"`
RestDurationMs int64 `json:"restDurationMs"` RestDurationMs int64 `json:"restDurationMs"`
} }

@ -90,7 +90,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartRoadsideRest) r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartRoadsideRest)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopRoadsideRest) r.Post("/heroes/{heroId}/stop-rest", adminH.StopRoadsideRest)
r.Post("/heroes/{heroId}/stop-roadside-rest", adminH.StopHeroRoadsideRest) r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroAdventure)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear) r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
@ -165,6 +165,7 @@ func New(deps Deps) *chi.Mux {
// Quest system routes. // Quest system routes.
r.Get("/towns", questH.ListTowns) r.Get("/towns", questH.ListTowns)
r.Get("/towns/{townId}/npcs", questH.ListNPCsByTown) r.Get("/towns/{townId}/npcs", questH.ListNPCsByTown)
r.Get("/towns/{townId}/buildings", questH.ListBuildingsByTown)
r.Get("/npcs/{npcId}/quests", questH.ListQuestsByNPC) r.Get("/npcs/{npcId}/quests", questH.ListQuestsByNPC)
r.Post("/hero/quests/{questId}/accept", questH.AcceptQuest) r.Post("/hero/quests/{questId}/accept", questH.AcceptQuest)
r.Get("/hero/quests", questH.ListHeroQuests) r.Get("/hero/quests", questH.ListHeroQuests)

@ -71,7 +71,7 @@ func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, er
// ListNPCsByTown returns all NPCs in the given town. // ListNPCsByTown returns all NPCs in the given town.
func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) { func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs FROM npcs
WHERE town_id = $1 WHERE town_id = $1
ORDER BY id ASC ORDER BY id ASC
@ -84,7 +84,7 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
var npcs []model.NPC var npcs []model.NPC
for rows.Next() { for rows.Next() {
var n model.NPC var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY); err != nil { if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
npcs = append(npcs, n) npcs = append(npcs, n)
@ -102,9 +102,9 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) { func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) {
var n model.NPC var n model.NPC
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs WHERE id = $1 FROM npcs WHERE id = $1
`, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY) `, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@ -117,7 +117,7 @@ func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, e
// ListAllNPCs returns every NPC across all towns. // ListAllNPCs returns every NPC across all towns.
func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs FROM npcs
ORDER BY town_id ASC, id ASC ORDER BY town_id ASC, id ASC
`) `)
@ -129,7 +129,7 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
var npcs []model.NPC var npcs []model.NPC
for rows.Next() { for rows.Next() {
var n model.NPC var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY); err != nil { if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
npcs = append(npcs, n) npcs = append(npcs, n)
@ -143,6 +143,65 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
return npcs, nil return npcs, nil
} }
// ListBuildingsByTown returns all buildings in the given town.
func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
FROM town_buildings
WHERE town_id = $1
ORDER BY id ASC
`, townID)
if err != nil {
return nil, fmt.Errorf("list buildings by town: %w", err)
}
defer rows.Close()
var buildings []model.TownBuilding
for rows.Next() {
var b model.TownBuilding
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
buildings = append(buildings, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list buildings rows: %w", err)
}
if buildings == nil {
buildings = []model.TownBuilding{}
}
return buildings, nil
}
// ListAllBuildings returns every building across all towns (for road_graph preload).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
FROM town_buildings
ORDER BY town_id ASC, id ASC
`)
if err != nil {
return nil, fmt.Errorf("list all buildings: %w", err)
}
defer rows.Close()
var buildings []model.TownBuilding
for rows.Next() {
var b model.TownBuilding
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
buildings = append(buildings, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list all buildings rows: %w", err)
}
if buildings == nil {
buildings = []model.TownBuilding{}
}
return buildings, nil
}
// ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range. // ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range.
func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `

@ -19,6 +19,7 @@ import {
getAdventureLog, getAdventureLog,
getTowns, getTowns,
getTownNPCs, getTownNPCs,
getTownBuildings,
getHeroQuests, getHeroQuests,
getHeroEquipment, getHeroEquipment,
claimQuest, claimQuest,
@ -30,7 +31,7 @@ import {
requestRevive, requestRevive,
} from './network/api'; } from './network/api';
import type { HeroResponse, Achievement } from './network/api'; import type { HeroResponse, Achievement } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem } from './game/types'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api'; import type { OfflineReport as OfflineReportData } from './network/api';
import { import {
BUFF_COOLDOWN_MS, BUFF_COOLDOWN_MS,
@ -202,14 +203,15 @@ function mapEquipment(
return out; return out;
} }
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs */ /** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData(town: Town, npcs?: NPC[]): TownData { function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): TownData {
const npcData: NPCData[] | undefined = npcs?.map((n) => ({ const npcData: NPCData[] | undefined = npcs?.map((n) => ({
id: n.id, id: n.id,
name: n.name, name: n.name,
type: n.type, type: n.type,
worldX: town.worldX + n.offsetX, worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY, worldY: town.worldY + n.offsetY,
buildingId: n.buildingId,
})); }));
return { return {
id: town.id, id: town.id,
@ -221,6 +223,7 @@ function townToTownData(town: Town, npcs?: NPC[]): TownData {
levelMin: town.levelMin, levelMin: town.levelMin,
size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS', size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS',
npcs: npcData, npcs: npcData,
buildings: buildings,
}; };
} }
@ -431,19 +434,22 @@ export function App() {
setTowns(t); setTowns(t);
townsRef.current = t; townsRef.current = t;
const townNPCMap = new Map<number, NPC[]>(); const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try { try {
const npcResults = await Promise.allSettled( const [npcResults, buildingResults] = await Promise.all([
t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
); Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) { for (const result of npcResults) {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
townNPCMap.set(result.value.townId, result.value.npcs);
} }
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id))); const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList); engine.setTowns(townDataList);
const allNPCs: NPCData[] = []; const allNPCs: NPCData[] = [];
for (const td of townDataList) { for (const td of townDataList) {
@ -480,19 +486,22 @@ export function App() {
setTowns(t); setTowns(t);
townsRef.current = t; townsRef.current = t;
const townNPCMap = new Map<number, NPC[]>(); const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try { try {
const npcResults = await Promise.allSettled( const [npcResults, buildingResults] = await Promise.all([
t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
); Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) { for (const result of npcResults) {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
townNPCMap.set(result.value.townId, result.value.npcs);
} }
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
} }
} catch { } catch {
console.warn('[App] Error fetching town NPCs'); console.warn('[App] Error fetching town NPCs/buildings');
} }
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id))); const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList); engine.setTowns(townDataList);
const allNPCs: NPCData[] = []; const allNPCs: NPCData[] = [];
for (const td of townDataList) { for (const td of townDataList) {
@ -954,17 +963,20 @@ export function App() {
.then(async (t) => { .then(async (t) => {
setTowns(t); setTowns(t);
const townNPCMap = new Map<number, NPC[]>(); const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try { try {
const npcResults = await Promise.allSettled( const [npcResults, buildingResults] = await Promise.all([
t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
); Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) { for (const result of npcResults) {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
townNPCMap.set(result.value.townId, result.value.npcs);
} }
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id))); const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList); engine.setTowns(townDataList);
const allNPCs: NPCData[] = []; const allNPCs: NPCData[] = [];
for (const td of townDataList) { for (const td of townDataList) {

@ -1,5 +1,6 @@
import { import {
CAMERA_FOLLOW_LERP, CAMERA_FOLLOW_LERP,
CAMERA_LERP_REFERENCE_MS,
SHAKE_MAGNITUDE, SHAKE_MAGNITUDE,
SHAKE_DURATION_MS, SHAKE_DURATION_MS,
} from '../shared/constants'; } from '../shared/constants';
@ -53,9 +54,12 @@ export class Camera {
/** Update camera position. Call once per frame with delta time in ms. */ /** Update camera position. Call once per frame with delta time in ms. */
update(dtMs: number): void { update(dtMs: number): void {
// Soft follow via linear interpolation // Exponential smoothing; same factor as legacy per-frame lerp at ~60 Hz reference.
this.x += (this.targetX - this.x) * this.lerpFactor; const k =
this.y += (this.targetY - this.y) * this.lerpFactor; 1 -
Math.pow(1 - this.lerpFactor, dtMs / CAMERA_LERP_REFERENCE_MS);
this.x += (this.targetX - this.x) * k;
this.y += (this.targetY - this.y) * k;
// Update screen shake // Update screen shake
if (this.shakeTimeRemaining > 0) { if (this.shakeTimeRemaining > 0) {

@ -1,7 +1,4 @@
import { import { MAX_ACCUMULATED_MS } from '../shared/constants';
FIXED_DT_MS,
MAX_ACCUMULATED_MS,
} from '../shared/constants';
import type { import type {
GameState, GameState,
EnemyState, EnemyState,
@ -11,6 +8,7 @@ import type {
TownData, TownData,
NearbyHeroData, NearbyHeroData,
NPCData, NPCData,
BuildingData,
} from './types'; } from './types';
import { GamePhase } from './types'; import { GamePhase } from './types';
import { GameRenderer, worldToScreen } from './renderer'; import { GameRenderer, worldToScreen } from './renderer';
@ -65,7 +63,6 @@ export class GameEngine {
private _running = false; private _running = false;
private _rafId: number | null = null; private _rafId: number | null = null;
private _lastTime = 0; private _lastTime = 0;
private _accumulator = 0;
/** Current game state (exposed to React via onStateChange) */ /** Current game state (exposed to React via onStateChange) */
private _gameState: GameState = { private _gameState: GameState = {
@ -505,8 +502,15 @@ export class GameEngine {
/** /**
* Called when server sends town_enter. * Called when server sends town_enter.
* If buildings are provided, merge them into the matching town for rendering.
*/ */
applyTownEnter(): void { applyTownEnter(townId?: number, buildings?: BuildingData[]): void {
if (townId && buildings && buildings.length > 0) {
const idx = this._towns.findIndex((t) => t.id === townId);
if (idx >= 0) {
this._towns[idx] = { ...this._towns[idx]!, buildings };
}
}
this._gameState = { this._gameState = {
...this._gameState, ...this._gameState,
phase: GamePhase.InTown, phase: GamePhase.InTown,
@ -642,7 +646,6 @@ export class GameEngine {
if (this._running) return; if (this._running) return;
this._running = true; this._running = true;
this._lastTime = performance.now(); this._lastTime = performance.now();
this._accumulator = 0;
this._tick(performance.now()); this._tick(performance.now());
} }
@ -674,22 +677,19 @@ export class GameEngine {
const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS); const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS);
this._lastTime = now; this._lastTime = now;
this._accumulator += frameTime;
// Fixed timestep updates (camera, loot timer only -- no game logic) // Interpolation + camera must run every frame. A 100ms fixed step (server tick rate)
while (this._accumulator >= FIXED_DT_MS) { // only updated ~10×/s and made the view stutter on 60 Hz displays.
this._update(FIXED_DT_MS); if (frameTime > 0) {
this._accumulator -= FIXED_DT_MS; this._update(frameTime);
} }
// Render (alpha available for future interpolation use)
void this._accumulator;
this._render(); this._render();
this._rafId = requestAnimationFrame(this._tick); this._rafId = requestAnimationFrame(this._tick);
}; };
/** Fixed-step update -- camera follow and loot timer only */ /** Per-frame update -- hero interpolation, camera follow, loot timer */
private _update(dtMs: number): void { private _update(dtMs: number): void {
// Interpolate hero display position toward target // Interpolate hero display position toward target
this._interpolatePosition(); this._interpolatePosition();

@ -204,40 +204,96 @@ export function proceduralTerrain(
return base; return base;
} }
/** Prop types for ground layer. */ /**
* Compute the distance from the nearest town edge (negative = inside town).
* Returns Infinity if no towns.
*/
function townEdgeDist(wx: number, wy: number, towns: TownTerrainInfluence[]): number {
let minEdge = Number.POSITIVE_INFINITY;
for (const t of towns) {
const d = Math.hypot(wx - t.cx, wy - t.cy) - t.radius;
if (d < minEdge) minEdge = d;
}
return minEdge;
}
/**
* Cluster-based noise: tiles near a "cluster seed" tile are more likely to share objects.
* Returns a hash that correlates with nearby tiles (spatial coherence ~3 tiles).
*/
function clusterHash(wx: number, wy: number, seed: number): number {
const cx = Math.floor(wx / 3);
const cy = Math.floor(wy / 3);
return tileHash(cx, cy, seed);
}
/**
* Prop types for ground layer.
* Uses zoning: plaza (sparse, server provides buildings), town edge (fences/barrels),
* road buffer (clear), wild (clustered natural objects).
*/
export function proceduralObject( export function proceduralObject(
wx: number, wx: number,
wy: number, wy: number,
terrain: string, terrain: string,
context?: WorldTerrainContext | null, context?: WorldTerrainContext | null,
): string | null { ): string | null {
// Inside plaza: no procedural props (server buildings handle this)
if (terrain === 'plaza') { if (terrain === 'plaza') {
const h = tileHash(wx, wy, 201);
if (h < 0.1) return 'stall';
if (h < 0.16) return 'well';
if (h < 0.22) return 'banner';
return null; return null;
} }
if (context && roadClearance(wx, wy, context) < 3.0) return null; const ctx = context;
const rd = ctx ? roadClearance(wx, wy, ctx) : Number.POSITIVE_INFINITY;
// Road buffer: keep clear for passage
if (rd < 3.0) return null;
// Town edge zone: sparse village decor (no trees/ruins)
if (ctx && ctx.towns.length > 0) {
const edge = townEdgeDist(wx, wy, ctx.towns);
if (edge >= -2.0 && edge < 5.0) {
const h = tileHash(wx, wy, 301);
if (h < 0.04) return 'barrel';
if (h < 0.065) return 'cart';
if (h < 0.085) return 'bush';
if (h < 0.095) return 'leaves';
return null;
}
}
// Wild zone: cluster-based natural objects for spatial coherence
const h = tileHash(wx, wy, 137); const h = tileHash(wx, wy, 137);
let treeTh = 0.045; const ch = clusterHash(wx, wy, 137);
if (terrain === 'forest_floor') treeTh = 0.09;
if (terrain === 'swamp_floor') treeTh = 0.025; // Trees appear in groves (cluster hash controls grove probability)
let treeTh = ch < 0.3 ? 0.08 : 0.02;
if (terrain === 'forest_floor') treeTh = ch < 0.4 ? 0.14 : 0.03;
if (terrain === 'swamp_floor') treeTh = ch < 0.25 ? 0.06 : 0.01;
if (h < treeTh) return 'tree'; if (h < treeTh) return 'tree';
if (h < 0.08) return 'bush';
if (h < 0.095) return 'rock'; // Bushes cluster around trees
if (h < 0.11) return 'stump'; const bushTh = ch < 0.35 ? 0.06 : 0.02;
if (h < 0.122) return 'cart'; if (h < treeTh + bushTh) return 'bush';
if (h < 0.132) return 'bones';
if (h < 0.142) return 'mushroom'; // Rocks appear in rocky patches
if (h < 0.15) return 'ruin'; const rockCh = clusterHash(wx, wy, 241);
if (rockCh < 0.2 && h < treeTh + bushTh + 0.04) return 'rock';
// Sparse scattered objects (less chaotic than before)
const sparse = tileHash(wx, wy, 199);
if (sparse < 0.008) return 'stump';
if (sparse < 0.014) return 'cart';
if (sparse < 0.018) return 'bones';
if (sparse < 0.024) return 'mushroom';
if (sparse < 0.028) return 'leaves';
if (terrain === 'ruins_floor' && sparse < 0.04) return 'ruin';
return null; return null;
} }
/** Blocking object types that hero cannot walk through */ /** Blocking object types that hero cannot walk through */
const BLOCKING_TYPES = new Set(['tree', 'bush', 'rock', 'ruin', 'stall', 'well']); const BLOCKING_TYPES = new Set(['tree', 'bush', 'rock', 'ruin', 'stall', 'well', 'barrel']);
/** /**
* Check if a tile at the given world coordinate is blocked by a procedural obstacle. * Check if a tile at the given world coordinate is blocked by a procedural obstacle.

@ -3,7 +3,7 @@ import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram'; import { getViewport } from '../shared/telegram';
import type { Camera } from './camera'; import type { Camera } from './camera';
import type { EnemyType } from './types'; import type { EnemyType } from './types';
import type { TownData, NPCData } from './types'; import type { TownData, NPCData, BuildingData } from './types';
import { drawEnemyByType } from './enemyVisuals'; import { drawEnemyByType } from './enemyVisuals';
/** /**
@ -211,6 +211,31 @@ export class GameRenderer {
gfx.fill({ color: 0x6a3a8e, alpha: 0.92 }); gfx.fill({ color: 0x6a3a8e, alpha: 0.92 });
} }
private _drawBarrel(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.15) * 2.8;
gfx.ellipse(x, y, 5 * s, 3 * s);
gfx.fill({ color: 0x5a4228, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 7 * s, 10 * s, 7 * s);
gfx.fill({ color: 0x6b4a30, alpha: 0.92 });
gfx.ellipse(x, y - 7 * s, 5 * s, 3 * s);
gfx.fill({ color: 0x7a5a3a, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 5 * s, 10 * s, 1 * s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, 1 * s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
}
private _drawLeafPile(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.25) * 2.5;
const colors = [0x6a8a2a, 0x8a7a22, 0x5a7a28, 0x9a8a30];
for (let i = 0; i < 4; i++) {
const ox = (((variant * 100 + i * 37) | 0) % 7 - 3) * s;
const oy = (((variant * 100 + i * 53) | 0) % 5 - 2) * s * 0.5;
gfx.ellipse(x + ox, y + oy, 4 * s, 2.5 * s);
gfx.fill({ color: colors[i % colors.length]!, alpha: 0.7 });
}
}
private _terrainColors(terrain: string, dark: boolean): number { private _terrainColors(terrain: string, dark: boolean): number {
if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75; if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75;
if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550; if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550;
@ -459,6 +484,8 @@ export class GameRenderer {
else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant); else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant);
else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant); else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant);
else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant); else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant);
else if (obj === 'barrel') this._drawBarrel(gfx, iso.x, iso.y, variant);
else if (obj === 'leaves') this._drawLeafPile(gfx, iso.x, iso.y, variant);
} }
} }
} }
@ -775,6 +802,140 @@ export class GameRenderer {
gfx.fill({ color: 0x44aa88, alpha: 0.7 }); gfx.fill({ color: 0x44aa88, alpha: 0.7 });
} }
/**
* Draw server-defined buildings for a town. Each building type gets a distinct
* visual style so players can identify NPC houses at a glance.
*/
private _drawServerBuildings(
gfx: Graphics,
buildings: BuildingData[],
_townScreenX: number,
_townScreenY: number,
scale: number,
): void {
for (let i = 0; i < buildings.length; i++) {
const b = buildings[i]!;
const bScreen = worldToScreen(b.worldX, b.worldY);
const bx = bScreen.x;
const by = bScreen.y;
const w = 60 * scale * (b.footprintW / 2.5);
const h = 48 * scale * (b.footprintH / 2.0);
const rh = 32 * scale;
const bt = b.buildingType;
if (bt === 'house.quest_giver') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
this._drawFence(gfx, bx, by, w, 'left');
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
} else if (bt === 'house.merchant') {
this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1);
this._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
} else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
} else if (bt === 'decoration.well') {
this._drawTownWell(gfx, bx, by, scale);
} else if (bt === 'decoration.stall') {
this._drawTownStall(gfx, bx, by, scale * 0.9);
} else if (bt === 'decoration.signpost') {
this._drawSignpost(gfx, bx, by, scale);
}
}
}
/** Draw a small icon circle above a building to indicate its purpose. */
private _drawBuildingIcon(
gfx: Graphics, cx: number, cy: number, _icon: string, color: number, scale: number,
): void {
const r = 6 * scale;
gfx.circle(cx, cy, r);
gfx.fill({ color, alpha: 0.6 });
gfx.stroke({ color: 0x000000, width: 1.2, alpha: 0.4 });
}
/** Draw a town well decoration (server-driven building). */
private _drawTownWell(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.ellipse(cx, cy, 10 * s, 5 * s);
gfx.fill({ color: 0x6a6a7a, alpha: 0.8 });
gfx.stroke({ color: 0x4a4a5a, width: 1.5, alpha: 0.6 });
gfx.rect(cx - 1 * s, cy - 12 * s, 2 * s, 12 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
gfx.rect(cx - 6 * s, cy - 13 * s, 12 * s, 2 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
}
/** Draw a signpost decoration. */
private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.rect(cx - 1 * s, cy - 16 * s, 2 * s, 16 * s);
gfx.fill({ color: 0x6a5a3a, alpha: 0.9 });
gfx.poly([
cx + 2 * s, cy - 14 * s,
cx + 12 * s, cy - 13 * s,
cx + 12 * s, cy - 10 * s,
cx + 2 * s, cy - 9 * s,
]);
gfx.fill({ color: 0x8a7a5a, alpha: 0.85 });
}
/**
* Fallback procedural building placement when server buildings are unavailable.
*/
private _drawProceduralBuildings(
gfx: Graphics, tx: number, ty: number, s: number,
spread: number, size: string, townSeed: number,
): void {
const houseCount = size === 'XS' ? 5 : size === 'S' ? 7 : size === 'M' ? 10 : 14;
const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
const baseW = 60;
const baseH = 48;
const baseRH = 32;
for (let i = 0; i < houseCount; i++) {
const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
const r1 = (hash & 0xffff) / 0xffff;
const r2 = ((hash >> 16) & 0xffff) / 0xffff;
const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;
const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
const dist = spread * (0.2 + r2 * 0.65);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.5;
const sizeVar = 0.7 + r3 * 0.5;
const w = baseW * s * sizeVar;
const h = baseH * s * sizeVar;
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
this._drawHouse(
gfx, tx + dx, ty + dy, w, h, rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
roofStyle,
);
if (i % 4 === 1) {
this._drawFence(gfx, tx + dx, ty + dy, w, i % 2 === 0 ? 'left' : 'right');
}
}
const stallCount = houseCount >= 10 ? 2 : 1;
for (let si = 0; si < stallCount; si++) {
const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1;
const stallDist = spread * 0.35;
this._drawTownStall(
gfx,
tx + Math.cos(stallAngle) * stallDist,
ty + Math.sin(stallAngle) * stallDist * 0.5,
s * 0.9,
);
}
}
/** /**
* Draw towns visible in the current viewport. * Draw towns visible in the current viewport.
* Each town renders a ground plane, a large cluster of buildings with detail, * Each town renders a ground plane, a large cluster of buildings with detail,
@ -839,77 +1000,14 @@ export class GameRenderer {
gfx.circle(tx, ty, borderRadius * 0.6); gfx.circle(tx, ty, borderRadius * 0.6);
gfx.fill({ color: 0xdaa520, alpha: 0.04 }); gfx.fill({ color: 0xdaa520, alpha: 0.04 });
// --- Building cluster: many houses spread wide --- // --- Buildings: server-driven if available, fallback procedural ---
const houseCount =
town.size === 'XS' ? 5 :
town.size === 'S' ? 7 :
town.size === 'M' ? 10 : 14;
// Generate house positions spread across a wider area
const housePositions: Array<{ dx: number; dy: number; w: number; h: number; rh: number; roofStyle: number; fence: boolean; stall: boolean }> = [];
const spread = 100 * s;
const baseW = 60;
const baseH = 48;
const baseRH = 32;
// Seed pseudo-random from town id for deterministic layout
const townSeed = typeof town.id === 'number' ? town.id : 0; const townSeed = typeof town.id === 'number' ? town.id : 0;
for (let i = 0; i < houseCount; i++) { const spread = 100 * s;
// Deterministic pseudo-random using a simple hash
const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
const r1 = ((hash & 0xffff) / 0xffff);
const r2 = (((hash >> 16) & 0xffff) / 0xffff);
const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;
// Angle-based layout to fill the town area
const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
const dist = spread * (0.2 + r2 * 0.65);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.5; // isometric compression
const sizeVar = 0.7 + r3 * 0.5;
const w = baseW * s * sizeVar;
const h = baseH * s * sizeVar;
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
const fence = i % 4 === 1;
const stall = false; // stalls are added separately
housePositions.push({ dx, dy, w, h, rh, roofStyle, fence, stall });
}
const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
for (let i = 0; i < housePositions.length; i++) {
const hp = housePositions[i]!;
this._drawHouse(
gfx,
tx + hp.dx,
ty + hp.dy,
hp.w,
hp.h,
hp.rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
hp.roofStyle,
);
if (hp.fence) {
this._drawFence(gfx, tx + hp.dx, ty + hp.dy, hp.w, i % 2 === 0 ? 'left' : 'right');
}
}
// Add 1-2 market stalls per town (larger towns get 2) if (town.buildings && town.buildings.length > 0) {
const stallCount = houseCount >= 10 ? 2 : 1; this._drawServerBuildings(gfx, town.buildings, tx, ty, s);
for (let si = 0; si < stallCount; si++) { } else {
const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1; this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed);
const stallDist = spread * 0.35;
this._drawTownStall(
gfx,
tx + Math.cos(stallAngle) * stallDist,
ty + Math.sin(stallAngle) * stallDist * 0.5,
s * 0.9,
);
} }
// --- Town name label (larger font, positioned higher) --- // --- Town name label (larger font, positioned higher) ---

@ -246,6 +246,7 @@ export interface NPC {
type: 'quest_giver' | 'merchant' | 'healer'; type: 'quest_giver' | 'merchant' | 'healer';
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
buildingId?: number;
} }
export interface Quest { export interface Quest {
@ -315,6 +316,18 @@ export interface NPCData {
type: 'quest_giver' | 'merchant' | 'healer'; type: 'quest_giver' | 'merchant' | 'healer';
worldX: number; worldX: number;
worldY: number; worldY: number;
buildingId?: number;
}
/** Server-driven building placed in a town */
export interface BuildingData {
id: number;
buildingType: string;
worldX: number;
worldY: number;
facing: 'north' | 'south' | 'east' | 'west';
footprintW: number;
footprintH: number;
} }
/** Alias: engine-facing town data for map rendering */ /** Alias: engine-facing town data for map rendering */
@ -329,6 +342,7 @@ export interface TownData {
levelMin: number; levelMin: number;
size: string; size: string;
npcs?: NPCData[]; npcs?: NPCData[];
buildings?: BuildingData[];
} }
/** NPC encounter event returned instead of an enemy */ /** NPC encounter event returned instead of an enemy */
@ -478,7 +492,16 @@ export interface TownEnterPayload {
townId: number; townId: number;
townName: string; townName: string;
biome?: string; biome?: string;
npcs?: Array<{ id: number; name: string; type: string }>; npcs?: Array<{ id: number; name: string; type: string; buildingId?: number }>;
buildings?: Array<{
id: number;
buildingType: string;
worldX: number;
worldY: number;
facing: string;
footprintW: number;
footprintH: number;
}>;
restDurationMs?: number; restDurationMs?: number;
} }

@ -147,7 +147,7 @@ export function wireWSHandler(
ws.on('town_enter', (msg: ServerMessage) => { ws.on('town_enter', (msg: ServerMessage) => {
const p = msg.payload as TownEnterPayload; const p = msg.payload as TownEnterPayload;
engine.applyTownEnter(); engine.applyTownEnter(p.townId, p.buildings as any);
callbacks.onTownEnter?.(p); callbacks.onTownEnter?.(p);
}); });

@ -436,7 +436,7 @@ export async function usePotion(telegramId?: number): Promise<HeroResponse> {
// ---- Towns ---- // ---- Towns ----
import type { Town, HeroQuest, NPC, Quest } from '../game/types'; import type { Town, HeroQuest, NPC, Quest, BuildingData } from '../game/types';
/** Fetch all towns */ /** Fetch all towns */
export async function getTowns(): Promise<Town[]> { export async function getTowns(): Promise<Town[]> {
@ -448,6 +448,11 @@ export async function getTownNPCs(townId: number): Promise<NPC[]> {
return apiGet<NPC[]>(`/towns/${townId}/npcs`); return apiGet<NPC[]>(`/towns/${townId}/npcs`);
} }
/** Fetch buildings for a town */
export async function getTownBuildings(townId: number): Promise<BuildingData[]> {
return apiGet<BuildingData[]>(`/towns/${townId}/buildings`);
}
/** Fetch available quests from an NPC */ /** Fetch available quests from an NPC */
export async function getNPCQuests(npcId: number, telegramId?: number): Promise<Quest[]> { export async function getNPCQuests(npcId: number, telegramId?: number): Promise<Quest[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const query = telegramId != null ? `?telegramId=${telegramId}` : '';

@ -19,6 +19,9 @@ export const TILE_HEIGHT = 48;
/** Camera follow lerp factor (0 = no follow, 1 = instant snap) */ /** Camera follow lerp factor (0 = no follow, 1 = instant snap) */
export const CAMERA_FOLLOW_LERP = 0.08; export const CAMERA_FOLLOW_LERP = 0.08;
/** Reference frame duration for dt-scaled camera smoothing (~60 Hz) */
export const CAMERA_LERP_REFERENCE_MS = 1000 / 60;
/** Map zoom level (<1 = zoomed out to show more tiles, 1 = default) */ /** Map zoom level (<1 = zoomed out to show more tiles, 1 = default) */
export const MAP_ZOOM = 1.0; export const MAP_ZOOM = 1.0;

@ -10,12 +10,11 @@ interface BuffStatusStripProps {
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
display: 'flex', display: 'flex',
gap: 4, gap: 4,
flexWrap: 'nowrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
flexShrink: 0, width: '100%',
maxWidth: 'min(46vw, 260px)', maxWidth: '100%',
overflowX: 'auto', minWidth: 0,
WebkitOverflowScrolling: 'touch',
pointerEvents: 'none', pointerEvents: 'none',
}; };

@ -68,10 +68,16 @@ const hpBuffRowStyle: CSSProperties = {
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
width: '100%', width: '100%',
marginTop: 8,
minWidth: 0, minWidth: 0,
}; };
const heroHpBuffColumnStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
width: '100%',
};
const hpBarFlex: CSSProperties = { const hpBarFlex: CSSProperties = {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
@ -90,6 +96,9 @@ const enemyNameStyle: CSSProperties = {
}; };
const bottomSection: CSSProperties = { const bottomSection: CSSProperties = {
display: 'flex',
justifyContent: 'center',
width: '100%',
pointerEvents: 'auto', pointerEvents: 'auto',
}; };
@ -218,7 +227,7 @@ export function HUD({
<span style={{ fontSize: 14 }}>&#x1F9EA;</span> {hero.potions} <span style={{ fontSize: 14 }}>&#x1F9EA;</span> {hero.potions}
</button> </button>
</div> </div>
<div> <div style={heroHpBuffColumnStyle}>
<div style={hpBuffRowStyle}> <div style={hpBuffRowStyle}>
<div <div
style={{ style={{
@ -284,9 +293,10 @@ export function HUD({
label={tr.hp} label={tr.hp}
/> />
</div> </div>
</div>
<div style={{ pointerEvents: 'auto' as const, width: '100%' }}>
<BuffStatusStrip buffs={hero.activeBuffs} nowMs={nowMs} /> <BuffStatusStrip buffs={hero.activeBuffs} nowMs={nowMs} />
</div> </div>
{/* Per-buff charge quotas are now shown on each BuffBar button */}
</div> </div>
<InventoryStrip hero={hero} lastLoot={lastVictoryLoot} /> <InventoryStrip hero={hero} lastLoot={lastVictoryLoot} />

Loading…
Cancel
Save