diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index f71aa44..86e9b61 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -388,6 +388,25 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { 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. cs, hasCombat := e.combats[msg.HeroID] enemyHP := 0 @@ -402,6 +421,9 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { HeroHP: hero.HP, 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 } +// 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). func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { e.mu.Lock() @@ -649,10 +701,8 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero e.sender.SendToHero(heroID, "hero_state", h) town := e.roadGraph.Towns[hm.CurrentTownID] if town != nil { - npcInfos := make([]model.TownNPCInfo, 0, len(e.roadGraph.TownNPCs[hm.CurrentTownID])) - for _, n := range e.roadGraph.TownNPCs[hm.CurrentTownID] { - npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type}) - } + npcInfos := e.roadGraph.TownNPCInfos(hm.CurrentTownID) + buildingInfos := e.roadGraph.TownBuildingInfos(hm.CurrentTownID) var restMs int64 if hm.State == model.StateResting { restMs = hm.RestUntil.Sub(now).Milliseconds() @@ -662,6 +712,7 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero TownName: town.Name, Biome: town.Biome, NPCs: npcInfos, + Buildings: buildingInfos, RestDurationMs: restMs, }) } @@ -845,6 +896,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { // Update movement state. if hm, ok := e.movements[hero.ID]; ok { hm.StartFighting() + hm.SyncToHero() } heap.Push(&e.queue, &model.AttackEvent{ @@ -1226,6 +1278,25 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { 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. if e.sender != nil { 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) } - delete(e.combats, cs.HeroID) - - // Resume walking. - if hm, ok := e.movements[cs.HeroID]; ok { - hm.ResumeWalking(now) - } - e.logger.Info("enemy defeated", "hero_id", cs.HeroID, "enemy", enemy.Name, @@ -1290,19 +1354,41 @@ func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) { } } -// processPositionSync sends drift-correction position_sync messages. -// Called at 0.1 Hz (every 10s). +// processPositionSync sends drift-correction position_sync messages and persists world (x,y). +// Called at low cadence (see tuning positionSyncRateMs). func (e *Engine) processPositionSync(now time.Time) { + type posSnap struct { + id int64 + x float64 + y float64 + } + var snaps []posSnap + 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 } - - for heroID, hm := range e.movements { - if hm.State == model.StateWalking { - e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, p := range snaps { + 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) } } } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 088298d..775d82b 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -76,10 +76,13 @@ type HeroMovement struct { // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). 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 AdventureEndAt time.Time 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. // 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. // 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) { defer hm.avoidSelfLoopDestination(graph) 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 { hm.CurrentTownID = graph.TownOrder[0] } else { @@ -277,6 +282,16 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) { 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 { hm.DestinationTownID = dest return @@ -446,6 +461,21 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow hm.refreshSpeed(now) 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 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] @@ -529,6 +559,8 @@ func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) { hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 } func (hm *HeroMovement) roadsideRestInProgress() bool { @@ -559,11 +591,9 @@ func (hm *HeroMovement) 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) { - hm.AdventureStartAt = time.Time{} - hm.AdventureEndAt = time.Time{} - hm.AdventureSide = 0 hm.RoadsideRestActive = true hm.RoadsideRestEndAt = endAt 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) { if hm.roadsideRestInProgress() { return @@ -681,6 +711,8 @@ func (hm *HeroMovement) tryStartAdventure(now time.Time) { spanNs = 1 } hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1))) + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 if rand.Float64() < 0.5 { hm.AdventureSide = 1 } else { @@ -711,6 +743,8 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool { } hm.AdventureStartAt = now hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1))) + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 if rand.Float64() < 0.5 { hm.AdventureSide = 1 } else { @@ -719,6 +753,21 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool { 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). func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { if graph == nil || townID == 0 { @@ -735,6 +784,8 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 hm.endRoadsideRest() hm.WanderingMerchantDeadline = time.Time{} hm.TownVisitNPCName = "" @@ -761,6 +812,8 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 hm.WanderingMerchantDeadline = time.Time{} hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} @@ -806,39 +859,76 @@ func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool { return true } -// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back. -// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.) -func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { +// adventureDepthFactor is 0 on the road, then smoothsteps in (RoadsideRestGoInMs), holds, then out before AdventureEndAt. +// 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) adventureDepthFactor(now time.Time) float64 { if !hm.adventureActive(now) { return 0 } - total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds() - if total <= 0 { + t0 := hm.AdventureStartAt + tEnd := hm.AdventureEndAt + if tEnd.IsZero() { return 0 } - elapsed := now.Sub(hm.AdventureStartAt).Seconds() - p := elapsed / total - if p < 0 { - p = 0 - } else if p > 1 { - p = 1 + if !now.Before(tEnd) { + return 0 } - r := tuning.Get().AdventureWildernessRampFraction - if r < 1e-6 { - r = 1e-6 + if t0.IsZero() { + t0 = tEnd.Add(-365 * 24 * time.Hour) + } + total := tEnd.Sub(t0) + if total <= 0 { + return 1 } - if r > 0.49 { - r = 0.49 + dtIn, dtOut := roadsideRestPhaseDurations(total) + if now.Before(t0) { + return 0 } - if p < r { - return p / r + if dtIn > 0 && now.Before(t0.Add(dtIn)) { + e := float64(now.Sub(t0)) / float64(dtIn) + return smoothstep01(e) } - if p > 1-r { - return (1 - p) / r + if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) { + e := float64(tEnd.Sub(now)) / float64(dtOut) + return smoothstep01(e) } 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 { if t <= 0 { return 0 @@ -943,6 +1033,17 @@ func roadsideRestDepthWorldUnits() float64 { 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) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 0, 1 @@ -965,6 +1066,29 @@ func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { 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) { if hm.roadsideRestInProgress() { if hm.RoadsideRestSide == 0 { @@ -975,13 +1099,20 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f return px * mag, py * mag } - w := hm.wildernessFactor(now) - if w <= 0 || hm.AdventureSide == 0 { - return 0, 0 + if hm.adventureActive(now) && hm.AdventureSide != 0 && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { + f := hm.adventureDepthFactor(now) + depth := adventureWildDepthWorldUnits() + cfg := tuning.Get() + if cfg.AdventureWildLateralMax > 0 { + if alt := cfg.AdventureWildLateralMax; alt > depth { + depth = alt + } + } + px, py := hm.roadPerpendicularUnit() + mag := float64(hm.AdventureSide) * depth * f + return px*mag + hm.AdventureWanderX, py*mag + hm.AdventureWanderY } - px, py := hm.roadPerpendicularUnit() - mag := float64(hm.AdventureSide) * tuning.Get().AdventureMaxLateral * w - return px * mag, py * mag + return 0, 0 } // 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.AdventureEndAt = time.Time{} hm.AdventureSide = 0 + hm.AdventureWanderX = 0 + hm.AdventureWanderY = 0 hm.endRoadsideRest() ids := graph.TownNPCIDs(destID) @@ -1108,9 +1241,12 @@ func (hm *HeroMovement) Die() { } // 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() { - hm.Hero.PositionX = hm.CurrentX - hm.Hero.PositionY = hm.CurrentY + now := time.Now() + ox, oy := hm.displayOffset(now) + hm.Hero.PositionX = hm.CurrentX + ox + hm.Hero.PositionY = hm.CurrentY + oy hm.Hero.State = hm.State if hm.CurrentTownID != 0 { id := hm.CurrentTownID @@ -1438,6 +1574,7 @@ func ProcessSingleHeroMovementTick( return case model.StateResting: + hm.expireAdventureIfNeeded(now) // Advance logical movement time while idle so leaving town does not apply a huge dt (teleport). dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { @@ -1574,8 +1711,7 @@ func ProcessSingleHeroMovementTick( sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } if hm.Hero != nil { - hm.Hero.PositionX = hm.CurrentX - hm.Hero.PositionY = hm.CurrentY + hm.SyncToHero() } return } @@ -1594,7 +1730,14 @@ func ProcessSingleHeroMovementTick( } hm.tryStartAdventure(now) + dtMove := now.Sub(hm.LastMoveTick).Seconds() + if dtMove <= 0 { + dtMove = movementTickRate().Seconds() + } reachedTown := hm.AdvanceTick(now, graph) + if !reachedTown { + hm.stepAdventureWander(now, dtMove) + } if reachedTown { hm.EnterTown(now, graph) @@ -1602,10 +1745,8 @@ func ProcessSingleHeroMovementTick( if sender != nil { town := graph.Towns[hm.CurrentTownID] if town != nil { - npcInfos := make([]model.TownNPCInfo, 0, len(graph.TownNPCs[hm.CurrentTownID])) - for _, n := range graph.TownNPCs[hm.CurrentTownID] { - npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type}) - } + npcInfos := graph.TownNPCInfos(hm.CurrentTownID) + buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID) var restMs int64 if hm.State == model.StateResting { restMs = hm.RestUntil.Sub(now).Milliseconds() @@ -1615,6 +1756,7 @@ func ProcessSingleHeroMovementTick( TownName: town.Name, Biome: town.Biome, NPCs: npcInfos, + Buildings: buildingInfos, RestDurationMs: restMs, }) } @@ -1664,7 +1806,6 @@ func ProcessSingleHeroMovementTick( sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } - hm.Hero.PositionX = hm.CurrentX - hm.Hero.PositionY = hm.CurrentY + hm.SyncToHero() } } diff --git a/backend/internal/game/road_graph.go b/backend/internal/game/road_graph.go index 8ddd4d6..e30a8d0 100644 --- a/backend/internal/game/road_graph.go +++ b/backend/internal/game/road_graph.go @@ -28,31 +28,46 @@ type Road struct { // TownNPC is a quest/shop NPC placed in a town (from npcs table). type TownNPC struct { - ID int64 - Name string - Type string + ID int64 + Name 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, // loaded once at startup. type RoadGraph struct { - Roads map[int64]*Road // road ID -> road - TownRoads map[int64][]*Road // town ID -> outgoing roads - Towns map[int64]*model.Town // town ID -> town - TownOrder []int64 // ordered town IDs for sequential traversal - TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order) - NPCByID map[int64]TownNPC // NPC id -> row + Roads map[int64]*Road // road ID -> road + TownRoads map[int64][]*Road // town ID -> outgoing roads + Towns map[int64]*model.Town // town ID -> town + TownOrder []int64 // ordered town IDs for sequential traversal + TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order) + NPCByID map[int64]TownNPC // NPC id -> row + TownBuildings map[int64][]TownBuilding // town ID -> buildings } // LoadRoadGraph reads roads and towns from the database, generates waypoints // deterministically, and returns an immutable RoadGraph. func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) { g := &RoadGraph{ - Roads: make(map[int64]*Road), - TownRoads: make(map[int64][]*Road), - Towns: make(map[int64]*model.Town), - TownNPCs: make(map[int64][]TownNPC), - NPCByID: make(map[int64]TownNPC), + Roads: make(map[int64]*Road), + TownRoads: make(map[int64][]*Road), + Towns: make(map[int64]*model.Town), + TownNPCs: make(map[int64][]TownNPC), + NPCByID: make(map[int64]TownNPC), + TownBuildings: make(map[int64][]TownBuilding), } // Load towns. @@ -74,7 +89,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) 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 { 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() { var n TownNPC 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) } 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) } + // 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. roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`) if err != nil { @@ -126,6 +158,43 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) 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). func (g *RoadGraph) TownNPCIDs(townID int64) []int64 { list := g.TownNPCs[townID] diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index d69cde1..86eccf3 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -62,6 +62,92 @@ type heroSummary struct { 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. // GET /admin/heroes?limit=20&offset=0 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 } - hero.RefreshDerivedCombatStats(time.Now()) - // 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) + h.writeAdminHeroDetail(w, hero) } type setLevelRequest struct { @@ -1306,8 +1386,6 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request }) return } - var hm = h.engine.GetMovements(heroID) - hero = hm.Hero if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { writeJSON(w, http.StatusBadRequest, map[string]any{ "error": "hero must be alive and not in combat", @@ -1331,9 +1409,8 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request }) return } - out.RefreshDerivedCombatStats(time.Now()) h.logger.Info("admin: start adventure", "hero_id", heroID) - writeJSON(w, http.StatusOK, out) + h.writeAdminHeroDetail(w, out) return } @@ -1348,7 +1425,70 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request return } 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). @@ -1483,9 +1623,8 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { }) return } - out.RefreshDerivedCombatStats(time.Now()) h.logger.Info("admin: start rest", "hero_id", heroID) - writeJSON(w, http.StatusOK, out) + h.writeAdminHeroDetail(w, out) return } @@ -1500,7 +1639,7 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { return } 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. @@ -1722,63 +1861,6 @@ func (h *AdminHandler) StopRoadsideRest(w http.ResponseWriter, r *http.Request) 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. // POST /admin/time/pause func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index e7e244a..f0598f5 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/denisovdennis/autohero/internal/model" "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) } +// 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. // GET /api/v1/npcs/{npcId}/quests func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/model/quest.go b/backend/internal/model/quest.go index 003e08b..0ed4d51 100644 --- a/backend/internal/model/quest.go +++ b/backend/internal/model/quest.go @@ -16,12 +16,13 @@ type Town struct { // NPC represents a non-hostile character living in a town. type NPC struct { - ID int64 `json:"id"` - TownID int64 `json:"townId"` - Name string `json:"name"` - Type string `json:"type"` // quest_giver, merchant, healer - OffsetX float64 `json:"offsetX"` - OffsetY float64 `json:"offsetY"` + ID int64 `json:"id"` + TownID int64 `json:"townId"` + Name string `json:"name"` + Type string `json:"type"` // quest_giver, merchant, healer + OffsetX float64 `json:"offsetX"` + OffsetY float64 `json:"offsetY"` + BuildingID *int64 `json:"buildingId,omitempty"` } // Quest is a template definition offered by a quest-giver NPC. @@ -62,25 +63,27 @@ type QuestReward struct { 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 { - ID int64 `json:"id"` - Name string `json:"name"` - Biome string `json:"biome"` - WorldX float64 `json:"worldX"` - WorldY float64 `json:"worldY"` - Radius float64 `json:"radius"` - Size string `json:"size"` // S, M, L derived from radius - NPCs []NPCView `json:"npcs"` + ID int64 `json:"id"` + Name string `json:"name"` + Biome string `json:"biome"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` + Radius float64 `json:"radius"` + Size string `json:"size"` // S, M, L derived from radius + NPCs []NPCView `json:"npcs"` + Buildings []BuildingView `json:"buildings"` } // NPCView is the frontend-friendly view of an NPC with absolute world coordinates. type NPCView struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - WorldX float64 `json:"worldX"` - WorldY float64 `json:"worldY"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` + BuildingID *int64 `json:"buildingId,omitempty"` } // TownSizeFromRadius derives a size label from the town radius. diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index 462a5f2..e12cdf0 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -116,18 +116,31 @@ type HeroRevivedPayload struct { // TownNPCInfo describes an NPC in a town (town_enter payload). type TownNPCInfo struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` + ID int64 `json:"id"` + Name string `json:"name"` + 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. type TownEnterPayload struct { - TownID int64 `json:"townId"` - TownName string `json:"townName"` - Biome string `json:"biome"` - NPCs []TownNPCInfo `json:"npcs"` - RestDurationMs int64 `json:"restDurationMs"` + TownID int64 `json:"townId"` + TownName string `json:"townName"` + Biome string `json:"biome"` + NPCs []TownNPCInfo `json:"npcs"` + Buildings []TownBuildingInfo `json:"buildings"` + RestDurationMs int64 `json:"restDurationMs"` } // TownNPCVisitPayload is sent when the hero approaches an NPC (quest/shop/healer) during a town stay. diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 48b74db..3b3c1e5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -90,7 +90,7 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartRoadsideRest) 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.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear) @@ -165,6 +165,7 @@ func New(deps Deps) *chi.Mux { // Quest system routes. r.Get("/towns", questH.ListTowns) r.Get("/towns/{townId}/npcs", questH.ListNPCsByTown) + r.Get("/towns/{townId}/buildings", questH.ListBuildingsByTown) r.Get("/npcs/{npcId}/quests", questH.ListQuestsByNPC) r.Post("/hero/quests/{questId}/accept", questH.AcceptQuest) r.Get("/hero/quests", questH.ListHeroQuests) diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 59c993f..96061ee 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -71,7 +71,7 @@ func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, er // ListNPCsByTown returns all NPCs in the given town. func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) { 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 WHERE town_id = $1 ORDER BY id ASC @@ -84,7 +84,7 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model. var npcs []model.NPC for rows.Next() { 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) } 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) { var n model.NPC 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 - `, 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 errors.Is(err, pgx.ErrNoRows) { 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. func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { 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 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 for rows.Next() { 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) } npcs = append(npcs, n) @@ -143,6 +143,65 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { 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. func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af7b746..1c31dd3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { getAdventureLog, getTowns, getTownNPCs, + getTownBuildings, getHeroQuests, getHeroEquipment, claimQuest, @@ -30,7 +31,7 @@ import { requestRevive, } 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 { BUFF_COOLDOWN_MS, @@ -202,14 +203,15 @@ function mapEquipment( return out; } -/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs */ -function townToTownData(town: Town, npcs?: NPC[]): TownData { +/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */ +function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): TownData { const npcData: NPCData[] | undefined = npcs?.map((n) => ({ id: n.id, name: n.name, type: n.type, worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, + buildingId: n.buildingId, })); return { id: town.id, @@ -221,6 +223,7 @@ function townToTownData(town: Town, npcs?: NPC[]): TownData { levelMin: town.levelMin, size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS', npcs: npcData, + buildings: buildings, }; } @@ -431,19 +434,22 @@ export function App() { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); + const townBuildingMap = new Map(); try { - const npcResults = await Promise.allSettled( - t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), - ); + const [npcResults, buildingResults] = await Promise.all([ + 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) { - if (result.status === 'fulfilled') { - townNPCMap.set(result.value.townId, result.value.npcs); - } + if (result.status === 'fulfilled') 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 */ } - 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); const allNPCs: NPCData[] = []; for (const td of townDataList) { @@ -480,19 +486,22 @@ export function App() { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); + const townBuildingMap = new Map(); try { - const npcResults = await Promise.allSettled( - t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), - ); + const [npcResults, buildingResults] = await Promise.all([ + 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) { - if (result.status === 'fulfilled') { - townNPCMap.set(result.value.townId, result.value.npcs); - } + if (result.status === 'fulfilled') 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 { - 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); const allNPCs: NPCData[] = []; for (const td of townDataList) { @@ -954,17 +963,20 @@ export function App() { .then(async (t) => { setTowns(t); const townNPCMap = new Map(); + const townBuildingMap = new Map(); try { - const npcResults = await Promise.allSettled( - t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), - ); + const [npcResults, buildingResults] = await Promise.all([ + 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) { - if (result.status === 'fulfilled') { - townNPCMap.set(result.value.townId, result.value.npcs); - } + if (result.status === 'fulfilled') 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 */ } - 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); const allNPCs: NPCData[] = []; for (const td of townDataList) { diff --git a/frontend/src/game/camera.ts b/frontend/src/game/camera.ts index a21f985..199e765 100644 --- a/frontend/src/game/camera.ts +++ b/frontend/src/game/camera.ts @@ -1,5 +1,6 @@ import { CAMERA_FOLLOW_LERP, + CAMERA_LERP_REFERENCE_MS, SHAKE_MAGNITUDE, SHAKE_DURATION_MS, } from '../shared/constants'; @@ -53,9 +54,12 @@ export class Camera { /** Update camera position. Call once per frame with delta time in ms. */ update(dtMs: number): void { - // Soft follow via linear interpolation - this.x += (this.targetX - this.x) * this.lerpFactor; - this.y += (this.targetY - this.y) * this.lerpFactor; + // Exponential smoothing; same factor as legacy per-frame lerp at ~60 Hz reference. + const k = + 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 if (this.shakeTimeRemaining > 0) { diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 368a03e..7b9480c 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -1,7 +1,4 @@ -import { - FIXED_DT_MS, - MAX_ACCUMULATED_MS, -} from '../shared/constants'; +import { MAX_ACCUMULATED_MS } from '../shared/constants'; import type { GameState, EnemyState, @@ -11,6 +8,7 @@ import type { TownData, NearbyHeroData, NPCData, + BuildingData, } from './types'; import { GamePhase } from './types'; import { GameRenderer, worldToScreen } from './renderer'; @@ -65,7 +63,6 @@ export class GameEngine { private _running = false; private _rafId: number | null = null; private _lastTime = 0; - private _accumulator = 0; /** Current game state (exposed to React via onStateChange) */ private _gameState: GameState = { @@ -505,8 +502,15 @@ export class GameEngine { /** * 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, phase: GamePhase.InTown, @@ -642,7 +646,6 @@ export class GameEngine { if (this._running) return; this._running = true; this._lastTime = performance.now(); - this._accumulator = 0; this._tick(performance.now()); } @@ -674,22 +677,19 @@ export class GameEngine { const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS); this._lastTime = now; - this._accumulator += frameTime; - // Fixed timestep updates (camera, loot timer only -- no game logic) - while (this._accumulator >= FIXED_DT_MS) { - this._update(FIXED_DT_MS); - this._accumulator -= FIXED_DT_MS; + // Interpolation + camera must run every frame. A 100ms fixed step (server tick rate) + // only updated ~10×/s and made the view stutter on 60 Hz displays. + if (frameTime > 0) { + this._update(frameTime); } - // Render (alpha available for future interpolation use) - void this._accumulator; this._render(); 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 { // Interpolate hero display position toward target this._interpolatePosition(); diff --git a/frontend/src/game/procedural.ts b/frontend/src/game/procedural.ts index 13b0b56..f7bd094 100644 --- a/frontend/src/game/procedural.ts +++ b/frontend/src/game/procedural.ts @@ -204,40 +204,96 @@ export function proceduralTerrain( 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( wx: number, wy: number, terrain: string, context?: WorldTerrainContext | null, ): string | null { + // Inside plaza: no procedural props (server buildings handle this) 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; } - 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); - let treeTh = 0.045; - if (terrain === 'forest_floor') treeTh = 0.09; - if (terrain === 'swamp_floor') treeTh = 0.025; + const ch = clusterHash(wx, wy, 137); + + // 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 < 0.08) return 'bush'; - if (h < 0.095) return 'rock'; - if (h < 0.11) return 'stump'; - if (h < 0.122) return 'cart'; - if (h < 0.132) return 'bones'; - if (h < 0.142) return 'mushroom'; - if (h < 0.15) return 'ruin'; + + // Bushes cluster around trees + const bushTh = ch < 0.35 ? 0.06 : 0.02; + if (h < treeTh + bushTh) return 'bush'; + + // Rocks appear in rocky patches + 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; } /** 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. diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 0f1d421..2eb51ae 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -3,7 +3,7 @@ import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants'; import { getViewport } from '../shared/telegram'; import type { Camera } from './camera'; import type { EnemyType } from './types'; -import type { TownData, NPCData } from './types'; +import type { TownData, NPCData, BuildingData } from './types'; import { drawEnemyByType } from './enemyVisuals'; /** @@ -211,6 +211,31 @@ export class GameRenderer { 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 { if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75; 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 === 'well') this._drawWell(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 }); } + /** + * 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. * 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.fill({ color: 0xdaa520, alpha: 0.04 }); - // --- Building cluster: many houses spread wide --- - 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 + // --- Buildings: server-driven if available, fallback procedural --- const townSeed = typeof town.id === 'number' ? town.id : 0; - for (let i = 0; i < houseCount; i++) { - // 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'); - } - } + const spread = 100 * s; - // Add 1-2 market stalls per town (larger towns get 2) - 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, - ); + if (town.buildings && town.buildings.length > 0) { + this._drawServerBuildings(gfx, town.buildings, tx, ty, s); + } else { + this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed); } // --- Town name label (larger font, positioned higher) --- diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 142140c..93865ff 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -246,6 +246,7 @@ export interface NPC { type: 'quest_giver' | 'merchant' | 'healer'; offsetX: number; offsetY: number; + buildingId?: number; } export interface Quest { @@ -315,6 +316,18 @@ export interface NPCData { type: 'quest_giver' | 'merchant' | 'healer'; worldX: 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 */ @@ -329,6 +342,7 @@ export interface TownData { levelMin: number; size: string; npcs?: NPCData[]; + buildings?: BuildingData[]; } /** NPC encounter event returned instead of an enemy */ @@ -478,7 +492,16 @@ export interface TownEnterPayload { townId: number; townName: 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; } diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index e541065..d9d0b67 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -147,7 +147,7 @@ export function wireWSHandler( ws.on('town_enter', (msg: ServerMessage) => { const p = msg.payload as TownEnterPayload; - engine.applyTownEnter(); + engine.applyTownEnter(p.townId, p.buildings as any); callbacks.onTownEnter?.(p); }); diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index a75094d..4252787 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -436,7 +436,7 @@ export async function usePotion(telegramId?: number): Promise { // ---- Towns ---- -import type { Town, HeroQuest, NPC, Quest } from '../game/types'; +import type { Town, HeroQuest, NPC, Quest, BuildingData } from '../game/types'; /** Fetch all towns */ export async function getTowns(): Promise { @@ -448,6 +448,11 @@ export async function getTownNPCs(townId: number): Promise { return apiGet(`/towns/${townId}/npcs`); } +/** Fetch buildings for a town */ +export async function getTownBuildings(townId: number): Promise { + return apiGet(`/towns/${townId}/buildings`); +} + /** Fetch available quests from an NPC */ export async function getNPCQuests(npcId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts index 972055d..4b6a9c0 100644 --- a/frontend/src/shared/constants.ts +++ b/frontend/src/shared/constants.ts @@ -19,6 +19,9 @@ export const TILE_HEIGHT = 48; /** Camera follow lerp factor (0 = no follow, 1 = instant snap) */ 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) */ export const MAP_ZOOM = 1.0; diff --git a/frontend/src/ui/BuffStatusStrip.tsx b/frontend/src/ui/BuffStatusStrip.tsx index 25646de..2a749ff 100644 --- a/frontend/src/ui/BuffStatusStrip.tsx +++ b/frontend/src/ui/BuffStatusStrip.tsx @@ -10,12 +10,11 @@ interface BuffStatusStripProps { const rowStyle: CSSProperties = { display: 'flex', gap: 4, - flexWrap: 'nowrap', + flexWrap: 'wrap', alignItems: 'center', - flexShrink: 0, - maxWidth: 'min(46vw, 260px)', - overflowX: 'auto', - WebkitOverflowScrolling: 'touch', + width: '100%', + maxWidth: '100%', + minWidth: 0, pointerEvents: 'none', }; diff --git a/frontend/src/ui/HUD.tsx b/frontend/src/ui/HUD.tsx index 6cbc9cd..f7f6776 100644 --- a/frontend/src/ui/HUD.tsx +++ b/frontend/src/ui/HUD.tsx @@ -68,10 +68,16 @@ const hpBuffRowStyle: CSSProperties = { alignItems: 'center', gap: 8, width: '100%', - marginTop: 8, minWidth: 0, }; +const heroHpBuffColumnStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + width: '100%', +}; + const hpBarFlex: CSSProperties = { flex: 1, minWidth: 0, @@ -90,6 +96,9 @@ const enemyNameStyle: CSSProperties = { }; const bottomSection: CSSProperties = { + display: 'flex', + justifyContent: 'center', + width: '100%', pointerEvents: 'auto', }; @@ -218,7 +227,7 @@ export function HUD({ 🧪 {hero.potions} -
+
+
+
- {/* Per-buff charge quotas are now shown on each BuffBar button */}