diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 9a28a95..ddebe74 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -324,9 +324,10 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) { return } - // When multiple roads are available, sometimes take a cross-road for variety. + // When multiple roads are available, sometimes pick a non-ring neighbor at random + // (includes both ring directions when there are exactly two outgoing roads). outgoing := graph.TownRoads[hm.CurrentTownID] - if len(outgoing) > 2 && rand.Float64() < crossRoadChance { + if len(outgoing) >= 2 && rand.Float64() < crossRoadChance { pick := outgoing[rand.Intn(len(outgoing))] if pick != nil && pick.ToTownID != hm.CurrentTownID { hm.DestinationTownID = pick.ToTownID diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index f7e0586..a3bc0b3 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -896,6 +896,73 @@ export class GameRenderer { gfx.fill({ color: 0x5a4a3a, alpha: 0.9 }); } + /** Small plaza fountain (procedural towns; server towns use decoration.well at center). */ + private _drawTownFountain(gfx: Graphics, cx: number, cy: number, s: number): void { + gfx.ellipse(cx, cy + 2 * s, 14 * s, 7 * s); + gfx.fill({ color: 0x4a5a6a, alpha: 0.85 }); + gfx.stroke({ color: 0x3a4550, width: 1.2, alpha: 0.65 }); + gfx.ellipse(cx, cy + 2 * s, 10 * s, 5 * s); + gfx.fill({ color: 0x5a8aaa, alpha: 0.45 }); + gfx.rect(cx - 3 * s, cy - 14 * s, 6 * s, 16 * s); + gfx.fill({ color: 0x7a7a88, alpha: 0.9 }); + gfx.rect(cx - 5 * s, cy - 16 * s, 10 * s, 3 * s); + gfx.fill({ color: 0x6a6a78, alpha: 0.88 }); + gfx.circle(cx, cy - 10 * s, 2.2 * s); + gfx.fill({ color: 0xaaddff, alpha: 0.35 }); + gfx.arc(cx, cy - 10 * s, 3 * s, -Math.PI * 0.85, -Math.PI * 0.15); + gfx.stroke({ color: 0x88ccff, width: 1.2, alpha: 0.4 }); + } + + /** + * Paved town square at the settlement center (under well / fountain and civic building). + */ + private _drawTownPlaza( + gfx: Graphics, + tx: number, + ty: number, + groundW: number, + groundH: number, + ): void { + const pw = groundW * 0.42; + const ph = groundH * 0.42; + gfx.ellipse(tx, ty, pw, ph); + gfx.fill({ color: 0x7a7878, alpha: 0.55 }); + gfx.stroke({ color: 0x5a5858, width: 1.2, alpha: 0.45 }); + const step = Math.max(10, pw * 0.14); + for (let dx = -pw + step * 0.3; dx < pw; dx += step) { + for (let dy = -ph + step * 0.25; dy < ph; dy += step * 0.85) { + if ((dx * dx) / (pw * pw) + (dy * dy) / (ph * ph) > 0.82) continue; + const h = ((Math.floor(dx / step) * 31) ^ (Math.floor(dy / step) * 17)) & 1; + gfx.rect(tx + dx - step * 0.08, ty + dy - step * 0.08, step * 0.45, step * 0.38); + gfx.fill({ color: h ? 0x6a686e : 0x757278, alpha: 0.35 }); + } + } + } + + /** Single civic building (hall / notice board) facing the plaza — not an NPC home. */ + private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void { + const w = 52 * s; + const h = 38 * s; + const rh = 26 * s; + gfx.rect(cx - w / 2, cy - h, w, h); + gfx.fill({ color: 0x8a9098, alpha: 0.95 }); + gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 }); + gfx.poly([ + cx - w / 2 - 4 * s, cy - h, + cx + w / 2 + 4 * s, cy - h, + cx + w / 2, cy - h - rh, + cx - w / 2, cy - h - rh, + ]); + gfx.fill({ color: 0x4a5560, alpha: 0.92 }); + gfx.rect(cx - 8 * s, cy - h * 0.65, 16 * s, 22 * s); + gfx.fill({ color: 0x2a3540, alpha: 0.75 }); + gfx.stroke({ color: 0x1a2530, width: 0.8, alpha: 0.5 }); + gfx.rect(cx - w / 2 + 6 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55); + gfx.fill({ color: 0x6a7580, alpha: 0.85 }); + gfx.rect(cx + w / 2 - 10 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55); + gfx.fill({ color: 0x6a7580, alpha: 0.85 }); + } + /** 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); @@ -931,7 +998,8 @@ export class GameRenderer { 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); + // Keep town center clear for plaza + fountain/well + civic building + const dist = spread * (0.36 + r2 * 0.52); const dx = Math.cos(angle) * dist; const dy = Math.sin(angle) * dist * 0.5; @@ -955,7 +1023,7 @@ export class GameRenderer { 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; + const stallDist = spread * 0.42; this._drawTownStall( gfx, tx + Math.cos(stallAngle) * stallDist, @@ -967,8 +1035,9 @@ export class GameRenderer { /** * Draw towns visible in the current viewport. - * Each town renders a ground plane, a large cluster of buildings with detail, - * market stalls, fences, a name label, and a dashed border. + * Each town renders a ground plane, a paved central plaza, a fountain or well at the + * plaza center (procedural fallback) or from server buildings, one civic hall offset + * from center, NPC homes and stalls, a name label, and a dashed border. */ drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void { const gfx = this._townGfx; @@ -1029,14 +1098,27 @@ export class GameRenderer { gfx.circle(tx, ty, borderRadius * 0.6); gfx.fill({ color: 0xdaa520, alpha: 0.04 }); - // --- Buildings: server-driven if available, fallback procedural --- + // --- Central plaza (paving); well/fountain + civic sit on or beside it --- + this._drawTownPlaza(gfx, tx, ty, groundW, groundH); + const townSeed = typeof town.id === 'number' ? town.id : 0; const spread = 100 * s; + const civicWx = town.centerX + 0.18 * town.radius; + const civicWy = town.centerY - 0.36 * town.radius; + const civicScreen = worldToScreen(civicWx, civicWy); + // --- Buildings: server-driven if available, fallback procedural --- if (town.buildings && town.buildings.length > 0) { this._drawServerBuildings(gfx, town.buildings, tx, ty, s); + this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s); } else { this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed); + if ((townSeed & 1) === 0) { + this._drawTownFountain(gfx, tx, ty, s); + } else { + this._drawTownWell(gfx, tx, ty, s); + } + this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s); } // --- Town name label (larger font, positioned higher) ---