namespace LivingVillage.Desktop open System open Microsoft.Xna.Framework.Graphics open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop.VillagePresentation module VillageArt = type PropKind = | WhiteWallDarkTileHouse | StoneBridge | Bamboo | VegetableGarden type TileKind = | RiverWater | StonePaving type ArtProp = { Kind: PropKind Position: TilePosition } type ArtTile = { Kind: TileKind Position: TilePosition } type JiangnanArtPlan = { Props: ArtProp list Tiles: ArtTile list } type GroundKind = | Water | StonePath type RenderPropKind = | House | Bridge | BambooGrove | VegetablePatch | WillowTree | ShrubPatch | FlowerBush | ReedCluster | StoneBench | RiverLantern | StoneLantern | WaterVat | MarketStall type RenderProp = { Kind: RenderPropKind Position: TilePosition } type InteriorElementKind = | Window | Table | Bed | Screen type InteriorElement = { Kind: InteriorElementKind Position: TilePosition } type InteriorPlan = { Elements: InteriorElement list } type JiangnanRenderPlan = { Origin: TilePosition Grounds: (TilePosition * GroundKind) list Props: RenderProp list Interior: InteriorPlan } type NpcVisualVariant = | IndigoRobe | OchreVest | JadeSash | GreyCap | StrawHat /// Presentation-only occupational archetype. Kernel NPCs carry no occupation, so the /// visible trade is a pure function of NpcId; three walking rhythms coexist on screen. type NpcTrade = | Farmer | Peddler | Scholar /// Per-trade cadence in simulation ticks plus a phase offset, all deterministic. type WalkRhythm = { WalkWindow: int64 IdleWindow: int64 PhaseOffset: int64 } type FacingDirection = | NorthFacing | SouthFacing | WestFacing | EastFacing type AnimationFrame = | FrameOne | FrameTwo | FrameThree | FrameFour /// Two-pose standing breath cycle used whenever a character is not moving. type IdleFrame = | IdleOne | IdleTwo /// A character either walks (four-pose cycle) or breathes in place (two-pose cycle). type CharacterFrame = | WalkFrame of AnimationFrame | IdleFrame of IdleFrame type NpcSpriteSpec = { Variant: NpcVisualVariant Direction: FacingDirection Frame: CharacterFrame } type ArtTextures = { TileAtlas: Texture2D CharacterAtlas: Texture2D InteriorAtlas: Texture2D Cc0TileAtlas: Texture2D option } type private TileSprite = | GrassSprite | WaterSprite | StonePathSprite | RoofSprite | WallSprite | BridgeSprite | BambooSprite | GardenSprite | RoofRidgeSprite | RoofEaveSprite | WallWoodSprite | WallWindowSprite | DoorSprite | WaterSpriteB | WillowTopSprite | WillowBaseSprite | ShrubSprite | FlowerSprite | ReedSprite | BenchSprite | LanternSprite | WaterSpriteC | SmokeSpriteA | SmokeSpriteB | SmokeSpriteC | StoneLanternSprite | WaterVatSprite | StallSprite | RoofEaveOverhangSprite | WallLatticeSprite | CourtyardDoorSprite | RevetmentStoneSprite type private XnaColor = Microsoft.Xna.Framework.Color type private XnaRectangle = Microsoft.Xna.Framework.Rectangle /// CC0 pack ground tiles: 0 grass bright / 1 grass deep (checker) / 2 path / 3 dirt. let private cc0GroundRect (position: TilePosition) : XnaRectangle = let index = if (position.X + position.Y) % 2 = 1 then 1 else 0 XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private cc0PathRect : XnaRectangle = XnaRectangle(2 * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private drawCc0Tile (spriteBatch: SpriteBatch) (cc0: Texture2D) (tint: XnaColor) (source: XnaRectangle) (screenX: int) (screenY: int) = spriteBatch.Draw(cc0, XnaRectangle(screenX, screenY, Sim.tilePixels, Sim.tilePixels), Nullable(source), tint) type private XnaVector2 = Microsoft.Xna.Framework.Vector2 let private position x y : TilePosition = { X = x Y = y } let private prop kind x y : ArtProp = { Kind = kind Position = position x y } let private tile kind x y : ArtTile = { Kind = kind Position = position x y } let samplePlan () : JiangnanArtPlan = { Props = [ prop WhiteWallDarkTileHouse 6 4 prop StoneBridge 10 4 prop Bamboo 3 2 prop VegetableGarden 8 7 ] Tiles = [ tile RiverWater 10 2 tile RiverWater 10 3 tile StonePaving 4 5 tile StonePaving 5 5 tile StonePaving 6 5 ] } let sampleRenderPlan () : JiangnanRenderPlan = let ground kind x y = position x y, kind let renderProp (kind: RenderPropKind) x y : RenderProp = { Kind = kind Position = position x y } let interiorElement kind x y : InteriorElement = { Kind = kind Position = position x y } { Origin = position 25 18 Grounds = [ ground StonePath 3 5 ground StonePath 4 5 ground StonePath 5 5 ground StonePath 6 5 ground StonePath 7 5 ground StonePath 8 5 ground StonePath 9 5 ground StonePath 10 5 ground StonePath 11 5 ground StonePath 6 6 ground StonePath 6 7 ground StonePath 6 8 ground StonePath 6 9 ground StonePath 6 10 for x in 0 .. 5 do ground StonePath x 11 for x in 9 .. 19 do ground StonePath x 11 for x in 1 .. 18 do ground StonePath x 14 ground Water 6 11 ground Water 7 11 ground Water 8 11 for x in 0 .. 19 do ground Water x 12 for x in 2 .. 17 do ground Water x 13 ] Props = [ renderProp House 6 5 renderProp Bridge 6 11 renderProp BambooGrove 3 2 renderProp VegetablePatch 8 7 renderProp WillowTree 1 9 renderProp WillowTree 13 9 renderProp ShrubPatch 1 7 renderProp ShrubPatch 10 7 renderProp FlowerBush 3 8 renderProp FlowerBush 12 8 renderProp ReedCluster 3 12 renderProp ReedCluster 15 13 renderProp StoneBench 9 11 renderProp RiverLantern 5 11 renderProp RiverLantern 7 14 renderProp StoneLantern 4 14 renderProp StoneLantern 16 14 renderProp WaterVat 12 11 renderProp MarketStall 3 11 ] Interior = { Elements = [ interiorElement Window 2 2 interiorElement Table 4 4 interiorElement Bed 7 3 interiorElement Screen 7 6 ] } } let worldTile (plan: JiangnanRenderPlan) (localPosition: TilePosition) : TilePosition = { X = plan.Origin.X + localPosition.X Y = plan.Origin.Y + localPosition.Y } /// Every tile a prop paints over, in world coordinates. Used by draw ordering and by /// spawn-overlap assertions so villagers never start inside a painted prop. let planFootprint (plan: JiangnanRenderPlan) : TilePosition list = let many positions = positions |> List.map (worldTile plan) plan.Props |> List.collect (fun prop -> let x, y = prop.Position.X, prop.Position.Y match prop.Kind with | House -> [ for dx in -2 .. 1 do for dy in -2 .. 0 do yield position (x + dx) (y + dy) ] | Bridge -> [ position x y; position x (y + 1); position x (y + 2) ] | BambooGrove -> [ position x y; position x (y + 1); position (x + 1) y ] | VegetablePatch -> [ position x y; position (x + 1) y ] | WillowTree -> [ position x y; position x (y + 1) ] | ShrubPatch | FlowerBush | ReedCluster | StoneBench | RiverLantern | StoneLantern | WaterVat | MarketStall -> [ position x y ]) |> many let private positiveModulo divisor value = let remainder = value % divisor if remainder < 0 then remainder + divisor else remainder let npcVisualVariant (NpcId npcId) : NpcVisualVariant = match positiveModulo 4 npcId with | 0 -> IndigoRobe | 1 -> OchreVest | 2 -> JadeSash | _ -> GreyCap let private positiveModulo64 divisor value = let remainder = value % divisor if remainder < 0L then remainder + divisor else remainder /// Trade cycle: NPCs repeat Farmer -> Peddler -> Scholar by id, so all three rhythms /// are present without touching kernel state. let npcTrade (NpcId npcId) : NpcTrade = match positiveModulo 3 npcId with | 0 -> Farmer | 1 -> Peddler | _ -> Scholar /// Farmer ambles at the original cadence; Peddler shuffles faster; Scholar strolls slower. let rhythmFor (trade: NpcTrade) : WalkRhythm = match trade with | Farmer -> { WalkWindow = 4L; IdleWindow = 24L; PhaseOffset = 0L } | Peddler -> { WalkWindow = 3L; IdleWindow = 20L; PhaseOffset = 1L } | Scholar -> { WalkWindow = 6L; IdleWindow = 36L; PhaseOffset = 2L } /// Four-pose walk cycle at a rhythm's window (one pose per WalkWindow sim ticks). let animationFrameFor (rhythm: WalkRhythm) (tick: int64) : AnimationFrame = match positiveModulo64 4L (tick / rhythm.WalkWindow + rhythm.PhaseOffset) with | 0L -> FrameOne | 1L -> FrameTwo | 2L -> FrameThree | _ -> FrameFour /// Two-pose standing breath cycle at a rhythm's IdleWindow, fully deterministic. let idleFrameFor (rhythm: WalkRhythm) (tick: int64) : IdleFrame = match positiveModulo64 2L (tick / rhythm.IdleWindow + rhythm.PhaseOffset) with | 0L -> IdleOne | _ -> IdleTwo /// Picks the walk cycle while moving and the breath cycle while standing still. let characterFrameFor (rhythm: WalkRhythm) (isMoving: bool) (tick: int64) : CharacterFrame = if isMoving then WalkFrame(animationFrameFor rhythm tick) else IdleFrame(idleFrameFor rhythm tick) /// Farmer cadence is the base cadence (window 4/24, phase 0), preserving older semantics. let animationFrame (tick: int64) : AnimationFrame = animationFrameFor (rhythmFor Farmer) tick let idleFrame (tick: int64) : IdleFrame = idleFrameFor (rhythmFor Farmer) tick let characterFrame (isMoving: bool) (tick: int64) : CharacterFrame = characterFrameFor (rhythmFor Farmer) isMoving tick /// Water surface ripple phase: three frames, one per 32 simulation ticks, fully deterministic. let waterFrameTick (tick: int64) : int = int (positiveModulo64 3L (tick / 32L)) /// Chimney smoke puff phase: three frames, one per 40 simulation ticks, fully deterministic. let smokeFrameTick (tick: int64) : int = int (positiveModulo64 3L (tick / 40L)) /// Per-tile water ripple phase. The global 32-tick cycle is offset by world tile /// coordinates, so neighbouring river tiles show different phases in the same frame /// instead of the whole surface switching in lockstep. let waterFrameTickAt (tick: int64) (tileX: int) (tileY: int) : int = int (positiveModulo64 3L (tick / 32L + int64 (tileX + 2 * tileY))) let directionFromVector (vector: Vec2) : FacingDirection = if abs vector.X < 0.001f && abs vector.Y < 0.001f then SouthFacing elif abs vector.X >= abs vector.Y then if vector.X >= 0.0f then EastFacing else WestFacing elif vector.Y >= 0.0f then SouthFacing else NorthFacing let facingAfterMovement (previousPosition: Vec2) (nextPosition: Vec2) (current: FacingDirection) : FacingDirection = let movement : Vec2 = { X = nextPosition.X - previousPosition.X Y = nextPosition.Y - previousPosition.Y } if abs movement.X < 0.001f && abs movement.Y < 0.001f then current else directionFromVector movement let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec = { Variant = npcVisualVariant npcId Direction = direction Frame = characterFrameFor (rhythmFor (npcTrade npcId)) isMoving tick } /// Villagers breathe while standing at their target and walk while approaching it. let npcSpriteSpecAtTarget (npcId: NpcId) (position: Vec2) (target: Vec2) (tick: int64) : NpcSpriteSpec = let movement : Vec2 = { X = target.X - position.X Y = target.Y - position.Y } let isMoving = abs movement.X >= 0.5f || abs movement.Y >= 0.5f npcSpriteSpec npcId (directionFromVector movement) isMoving tick let characterSourceRectangle (spec: NpcSpriteSpec) : Microsoft.Xna.Framework.Rectangle = let variantIndex = match spec.Variant with | IndigoRobe -> 0 | OchreVest -> 1 | JadeSash -> 2 | GreyCap -> 3 | StrawHat -> 4 let directionIndex = match spec.Direction with | NorthFacing -> 0 | SouthFacing -> 1 | WestFacing -> 2 | EastFacing -> 3 let frameIndex = match spec.Frame with | WalkFrame FrameOne -> 0 | WalkFrame FrameTwo -> 1 | WalkFrame FrameThree -> 2 | WalkFrame FrameFour -> 3 | IdleFrame IdleOne -> 4 | IdleFrame IdleTwo -> 5 let cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex Microsoft.Xna.Framework.Rectangle(cellIndex * 32, 0, 32, 48) let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) /// MonoGame's default SpriteBatch blend (BlendState.AlphaBlend) treats the sprite /// format as premultiplied alpha: the source colour blend is `One`, so a straight /// Color(r,g,b,a) adds its raw rgb to the framebuffer and layered glows saturate to /// an opaque white block. Scale every channel by its own alpha before drawing. let premultiply (r: int) (g: int) (b: int) (a: int) : Microsoft.Xna.Framework.Color = let clampChannel (channel: int) = max 0 (min 255 channel) let alpha = clampChannel a let scale (channel: int) = Math.Round(float (clampChannel channel) * float alpha / 255.0, MidpointRounding.AwayFromZero) |> int Microsoft.Xna.Framework.Color(scale r, scale g, scale b, alpha) /// Horizontal pixel runs approximating a filled ellipse; used for soft contact shadows. let ellipseRuns (centerX: int) (centerY: int) (radiusX: int) (radiusY: int) : (int * int * int) list = if radiusX <= 0 || radiusY <= 0 then [] else [ for dy in -radiusY .. radiusY do let t = float dy / float radiusY let half = int (Math.Round(float radiusX * Math.Sqrt(max 0.0 (1.0 - t * t)))) if half > 0 then yield (centerX - half, centerY + dy, half * 2 + 1) ] /// P29 夜景灯光径向衰减:半径 0 处为 1(最亮),距离达到 radius 处为 0;中间按二次 /// `1 - (d/r)^2` 平滑递减,严格单调、无平台(既没有硬边方斑,也不会出现等亮台阶)。 /// 纯函数、无时钟/随机源,供灯笼/窗光光晕与单测共用。 let lanternFalloff (distance: float32) (radius: float32) : float32 = if radius <= 0.0f then 0.0f elif distance <= 0.0f then 1.0f elif distance >= radius then 0.0f else let t = distance / radius 1.0f - t * t /// Soft radial lantern glow drawn as concentric filled circles. Each ring's alpha is the /// incremental step of `lanternFalloff` at that ring radius, so the accumulated warm alpha /// follows the smooth quadratic falloff from the core out to `radius` instead of stepping or /// flattening. Because every ring's rgb is premultiplied by its own low alpha, the core can /// never saturate to an opaque white block the way straight rgba layers did. let lanternGlowLayers (centerX: int) (centerY: int) (radius: int) (rings: int) (red: int) (green: int) (blue: int) (peakAlpha: int) : (Microsoft.Xna.Framework.Rectangle * Microsoft.Xna.Framework.Color) list = if radius <= 0 || rings <= 0 || peakAlpha <= 0 then [] else let radiusF = float32 radius let targetAlpha (ringRadius: int) = let falloff = lanternFalloff (float32 ringRadius) radiusF int (Math.Round(float peakAlpha * float falloff, MidpointRounding.AwayFromZero)) let mutable previousTarget = 0 [ for ring in 0 .. rings - 1 do let ringRadius = max 1 (radius * (rings - ring) / rings) let target = targetAlpha ringRadius let increment = max 0 (target - previousTarget) previousTarget <- max previousTarget target if increment > 0 then let glowColor = premultiply red green blue increment for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), glowColor) ] /// Characters spawn at tile centres; push the sprite down half a tile so its feet rest /// on the tile's ground line instead of floating at the centre. let characterFeetOffset = Sim.tilePixels / 2 /// Premultiplied translucent shadow colour used beneath characters. let shadowColor : Microsoft.Xna.Framework.Color = premultiply 12 16 22 96 let characterDestination (position: Vec2) (camera: Microsoft.Xna.Framework.Vector2) : Microsoft.Xna.Framework.Rectangle = Microsoft.Xna.Framework.Rectangle( int position.X - int camera.X - 16, int position.Y - int camera.Y - 48 + characterFeetOffset, 32, 48) let drawGroundShadow (spriteBatch: SpriteBatch) (pixel: Texture2D) (camera: Microsoft.Xna.Framework.Vector2) (position: Vec2) = let centerX = int position.X - int camera.X let centerY = int position.Y - int camera.Y + characterFeetOffset - 2 for (x, y, width) in ellipseRuns centerX centerY 13 5 do spriteBatch.Draw(pixel, Microsoft.Xna.Framework.Rectangle(x, y, width, 1), shadowColor) let avatarSpriteSpec (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec = { Variant = StrawHat Direction = direction Frame = characterFrame isMoving tick } let worldAtlasRelativePath = "Assets/jiangnan-world.png" let characterAtlasRelativePath = "Assets/jiangnan-characters.png" let interiorAtlasRelativePath = "Assets/jiangnan-interior.png" let worldTileCount = 32 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels let characterAtlasWidth = 32 * 5 * 4 * 6 let characterAtlasHeight = 48 let interiorAtlasWidth = 392 let interiorAtlasHeight = 96 let private loadTexture (device: GraphicsDevice) (relativePath: string) (expectedWidth: int) (expectedHeight: int) : Texture2D = let fullPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, relativePath) if not (System.IO.File.Exists fullPath) then raise (System.IO.FileNotFoundException(sprintf "Jiangnan art atlas is missing: %s" fullPath, fullPath)) use stream = System.IO.File.OpenRead fullPath let texture = Texture2D.FromStream(device, stream) if texture.Width <> expectedWidth || texture.Height <> expectedHeight then texture.Dispose() invalidOp (sprintf "Jiangnan art atlas %s has dimensions %dx%d; expected %dx%d." fullPath texture.Width texture.Height expectedWidth expectedHeight) else texture /// Fallback switch for the real-pixel asset pack (P33 vertical slice). When /// `LV_ASSET_PACK=cc0`, the sample village swaps its ground + villager atlases for /// the provenanced CC0 packs; any other value keeps the procedural Jiangnan atlases. let useCc0Pack () : bool = System.Environment.GetEnvironmentVariable("LV_ASSET_PACK") = "cc0" let cc0TileAtlasRelativePath = "Assets/cc0-tiles.png" // Source ground tiles are 16x16, upscaled exactly 2x to one Sim.tilePixels cell. let cc0TileSourcePixels = 16 let cc0TileAtlasWidth = Sim.tilePixels * 4 let cc0TileAtlasHeight = Sim.tilePixels let cc0CharacterAtlasRelativePath = "Assets/cc0-characters.png" // Source Puny band is 16x24, upscaled exactly 2x to a 32x48 cell; atlas packs // 5 variants x 4 directions x 6 frames. let cc0CharacterSourceWidth = 16 let cc0CharacterSourceHeight = 24 let cc0CharacterCellWidth = 32 let cc0CharacterCellHeight = 48 let cc0CharacterVariantCount = 5 let cc0CharacterDirectionCount = 4 let cc0CharacterFrameCount = 6 let cc0CharacterAtlasWidth = cc0CharacterCellWidth * cc0CharacterVariantCount * cc0CharacterDirectionCount * cc0CharacterFrameCount /// Ground atlas selection: `Some cc0-tiles.png` when the pack is on, else `None` /// so the renderer keeps its procedural Jiangnan ground as the fallback. let selectedGroundAtlasRelativePath () : string option = if useCc0Pack () then Some cc0TileAtlasRelativePath else None /// Character atlas selection: real CC0 Puny sprites when the pack is on, else the /// procedural Jiangnan character atlas (fallback). let selectedCharacterAtlasRelativePath () : string = if useCc0Pack () then cc0CharacterAtlasRelativePath else characterAtlasRelativePath let loadTextures (device: GraphicsDevice) : ArtTextures = let cc0 = selectedGroundAtlasRelativePath () |> Option.map (fun path -> loadTexture device path cc0TileAtlasWidth cc0TileAtlasHeight) { TileAtlas = loadTexture device worldAtlasRelativePath worldAtlasWidth worldAtlasHeight CharacterAtlas = loadTexture device (selectedCharacterAtlasRelativePath ()) cc0CharacterAtlasWidth cc0CharacterCellHeight InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight Cc0TileAtlas = cc0 } let atlasTileRectangle (index: int) : Microsoft.Xna.Framework.Rectangle = XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private tileSourceRectangle sprite : XnaRectangle = let index = match sprite with | GrassSprite -> 0 | WaterSprite -> 1 | StonePathSprite -> 2 | RoofSprite -> 3 | WallSprite -> 4 | BridgeSprite -> 5 | BambooSprite -> 6 | GardenSprite -> 7 | RoofRidgeSprite -> 8 | RoofEaveSprite -> 9 | WallWoodSprite -> 10 | WallWindowSprite -> 11 | DoorSprite -> 12 | WaterSpriteB -> 13 | WillowTopSprite -> 14 | WillowBaseSprite -> 15 | ShrubSprite -> 16 | FlowerSprite -> 17 | ReedSprite -> 18 | BenchSprite -> 19 | LanternSprite -> 20 | WaterSpriteC -> 21 | SmokeSpriteA -> 22 | SmokeSpriteB -> 23 | SmokeSpriteC -> 24 | StoneLanternSprite -> 25 | WaterVatSprite -> 26 | StallSprite -> 27 | RoofEaveOverhangSprite -> 28 | WallLatticeSprite -> 29 | CourtyardDoorSprite -> 30 | RevetmentStoneSprite -> 31 XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private tilePosition x y : TilePosition = { X = x Y = y } let private isVisible (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) (position: TilePosition) = let screenX = position.X * Sim.tilePixels - int camera.X let screenY = position.Y * Sim.tilePixels - int camera.Y screenX < viewportWidth && screenX + Sim.tilePixels > 0 && screenY < viewportHeight && screenY + Sim.tilePixels > 0 let private drawTile (spriteBatch: SpriteBatch) (atlas: Texture2D) (camera: XnaVector2) (position: TilePosition) sprite tint = let destination = XnaRectangle( position.X * Sim.tilePixels - int camera.X, position.Y * Sim.tilePixels - int camera.Y, Sim.tilePixels, Sim.tilePixels) spriteBatch.Draw(atlas, destination, tileSourceRectangle sprite, tint) let private drawWorldTile (spriteBatch: SpriteBatch) (textures: ArtTextures) (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) position sprite tint = if isVisible camera viewportWidth viewportHeight position then drawTile spriteBatch textures.TileAtlas camera position sprite tint let private offsetTile (position: TilePosition) dx dy : TilePosition = tilePosition (position.X + dx) (position.Y + dy) let private fillRect (spriteBatch: SpriteBatch) (pixel: Texture2D) (color: XnaColor) (x: int) (y: int) (w: int) (h: int) = spriteBatch.Draw(pixel, XnaRectangle(x, y, max 1 w, max 1 h), color) /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。 /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 亮青灰挑檐(多叠一层、向两侧各出一格)+ /// 竖长木格栅窗 + 绿院门(P28 强化配色与轮廓,保证 512 整图 ~2px/tile 下两变体可分)。 /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。 let houseFacadeVariant (building: MapGen.Building) : int = let remainder = (building.Left + building.Top) % 2 if remainder < 0 then remainder + 2 else remainder /// 生成器世界里的桥面与沿河民居(白墙黑瓦色块 + 门);坐标由调用方按相机换算。 let private drawMapStructures (spriteBatch: SpriteBatch) (textures: ArtTextures) (pixel: Texture2D) (tileX: int -> int) (tileY: int -> int) (cell: int) (map: MapGen.Result) = let bridgeSource = tileSourceRectangle BridgeSprite let drawCell sprite x y = spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle sprite, XnaColor.White) for (x, y) in map.Bridges do spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), bridgeSource, XnaColor.White) let drawHouse (building: MapGen.Building) = let left = building.Left let right = building.Left + building.Width - 1 match houseFacadeVariant building with | 0 -> // 变体 A(P24):黛瓦(脊/檐)+ 木墙/窗 + 门。 for row in building.Top .. building.DoorY - 1 do let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite for x in left .. right do drawCell roofSprite x row for x in left .. right do let wallSprite = if x = building.DoorX then DoorSprite elif (x - left) % 2 = 0 then WallWindowSprite else WallWoodSprite drawCell wallSprite x building.DoorY | _ -> // 变体 B(P26/P28):亮青灰挑檐向两侧各出一格 + 竖长木格栅窗 + 绿院门。 // P28 再向上叠一层挑檐,使 B 的轮廓比 A 高一格;水面之上不叠,避免屋顶压水。 let isWater x y = x >= 0 && x < map.Width && y >= 0 && y < map.Height && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water for row in building.Top .. building.DoorY - 1 do for x in (left - 1) .. (right + 1) do drawCell RoofEaveOverhangSprite x row let upper = building.Top - 1 if upper >= 0 then for x in (left - 1) .. (right + 1) do if not (isWater x upper) then drawCell RoofEaveOverhangSprite x upper for x in left .. right do let wallSprite = if x = building.DoorX then CourtyardDoorSprite else WallLatticeSprite drawCell wallSprite x building.DoorY for building in map.Buildings do drawHouse building // P30 散落农舍与沿河民居共用同一套立面纯函数。 for farmhouse in map.Farmhouses do drawHouse farmhouse // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。 for decoration in map.Decorations do match decoration.Kind with | MapGen.RiverDecorationKind.RevetmentStone -> drawCell RevetmentStoneSprite decoration.X decoration.Y | MapGen.RiverDecorationKind.Reeds -> drawCell ReedSprite decoration.X decoration.Y | MapGen.RiverDecorationKind.Willow -> drawCell WillowTopSprite decoration.X decoration.Y drawCell WillowBaseSprite decoration.X (decoration.Y + 1) // P30 乡村树丛:竹丛 / 灌木丛(纯视觉,不写瓦片、不影响通行)。 for grove in map.Groves do match grove.Kind with | MapGen.BambooClump -> drawCell BambooSprite grove.X grove.Y | MapGen.ShrubClump -> drawCell ShrubSprite grove.X grove.Y /// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。 let bridgeLanternTiles (map: MapGen.Result) : (int * int) list = map.Bridges |> List.groupBy fst |> List.collect (fun (x, entries) -> let ys = entries |> List.map snd [ (x, List.min ys); (x, List.max ys) ]) |> List.distinct /// P29 夜景灯光源瓦片:桥头灯笼 + 民居门窗暖光(确定性纯函数,便于单测)。 /// 每户至少门格发光;变体 A 再取第一扇横向窗,变体 B(整面格栅窗)与门同格故不另加。 /// P30:散落农舍的门窗暖光一并计入。 let nightLightTiles (map: MapGen.Result) : (int * int) list = let lanterns = bridgeLanternTiles map let houseLights = (map.Buildings @ map.Farmhouses) |> List.collect (fun building -> let windowTiles = if houseFacadeVariant building = 0 then [ for x in building.Left .. building.Left + building.Width - 1 do if x <> building.DoorX && (x - building.Left) % 2 = 0 then yield (x, building.DoorY) ] else [ (building.DoorX, building.DoorY) ] (building.DoorX, building.DoorY) :: windowTiles) lanterns @ houseLights |> List.distinct /// P29 夜景光晕叠加层:对每个灯光源画一圈柔和径向衰减(半径 3-6 tile), /// 由 `lanternGlowLayers` 保证无硬方斑、无等亮台阶。纯绘制,不改瓦片/可达性。 let drawNightGlows (spriteBatch: SpriteBatch) (pixel: Texture2D) (map: MapGen.Result) (radiusPixels: int) (peakAlpha: int) (tileX: int -> int) (tileY: int -> int) (cell: int) : unit = if peakAlpha > 0 && radiusPixels > 0 then let half = cell / 2 for (lightX, lightY) in nightLightTiles map do let centerX = tileX lightX + half let centerY = tileY lightY + half for (rect, color) in lanternGlowLayers centerX centerY radiusPixels 8 255 214 150 peakAlpha do spriteBatch.Draw(pixel, rect, color) let drawWorld (spriteBatch: SpriteBatch) (textures: ArtTextures) (pixel: Texture2D) (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) (plan: JiangnanRenderPlan) (mapGen: MapGen.Result option) (tick: int64) (tint: XnaColor) = let draw position sprite = drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint // Each water tile picks its phase from its own world coordinates, so a single // frame shows the three ripple phases across the river instead of one global frame. let waterSpriteAt (position: TilePosition) = match waterFrameTickAt tick position.X position.Y with | 0 -> WaterSprite | 1 -> WaterSpriteB | _ -> WaterSpriteC // Procedural terrain for the 512x384 large world: water/pebble/peat codes map // onto the jiangnan atlas sprites with deterministic ripple continuity. // 生成器世界:1 水 / 2 石板路 / 3 泥炭 / 0 草地。泥炭按草地渲染,路走石板贴图。 let drawTerrainTile (position: TilePosition) (code: int) = match code with | 1 -> draw position (waterSpriteAt position) | 2 -> draw position StonePathSprite // P30 乡村空区:水田(4)走静态水面素材、菜畦(5)走菜地素材;均为成熟 tile。 | 4 -> draw position WaterSprite | 5 -> draw position GardenSprite | _ -> draw position GrassSprite let drawBaseTile (position: TilePosition) = match mapGen with | Some map -> drawTerrainTile position map.Tiles.[position.Y * map.Width + position.X] | None -> match ProceduralMap.terrainOption, ProceduralMap.bigWorldActive with | (Some terrain, true) -> let tx, ty = position.X, position.Y let code = terrain.Tiles.[ty * ProceduralMap.width + tx] drawTerrainTile position code | _ -> match textures.Cc0TileAtlas with | Some cc0 -> drawCc0Tile spriteBatch cc0 tint (cc0GroundRect position) (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y) | None -> draw position GrassSprite match textures.Cc0TileAtlas with | Some cc0 -> drawCc0Tile spriteBatch cc0 tint (cc0GroundRect position) (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y) | None -> draw position GrassSprite let tx0, ty0, tx1, ty1 = MapGen.visibleTileRange Sim.mapWidthTiles Sim.mapHeightTiles (int camera.X) (int camera.Y) viewportWidth viewportHeight for y in ty0 .. ty1 do for x in tx0 .. tx1 do drawBaseTile (tilePosition x y) // 生成器世界:叠加桥面、沿河民居、桥头灯笼;不再绘制 64x48 样例平面与道具。 match mapGen with | Some map -> let mapTileX x = x * Sim.tilePixels - int camera.X let mapTileY y = y * Sim.tilePixels - int camera.Y drawMapStructures spriteBatch textures pixel mapTileX mapTileY Sim.tilePixels map for (lanternX, lanternY) in bridgeLanternTiles map do draw (tilePosition lanternX lanternY) LanternSprite | None -> () // 回退(64x48 样例世界)或 LV_MAP_SCALE 大世界时仍绘制样例地面/道具。 let planGrounds = match mapGen with Some _ -> [] | None -> plan.Grounds for localPosition, kind in planGrounds do let position = worldTile plan localPosition match kind with | Water -> draw position (waterSpriteAt position) | StonePath -> match textures.Cc0TileAtlas with | Some cc0 -> drawCc0Tile spriteBatch cc0 tint cc0PathRect (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y) | None -> draw position StonePathSprite let drawHouse doorTile = let position = worldTile plan doorTile for dx in -2 .. 1 do draw (offsetTile position dx -2) RoofRidgeSprite draw (offsetTile position dx -1) RoofEaveSprite draw (offsetTile position -2 0) WallWoodSprite draw (offsetTile position -1 0) WallWindowSprite draw (offsetTile position 0 0) DoorSprite draw (offsetTile position 1 0) WallWoodSprite let drawBridge localPosition = let position = worldTile plan localPosition draw position BridgeSprite draw (offsetTile position 0 1) BridgeSprite draw (offsetTile position 0 2) BridgeSprite let drawBamboo localPosition = let position = worldTile plan localPosition draw position BambooSprite draw (offsetTile position 0 1) BambooSprite draw (offsetTile position 1 0) BambooSprite let drawGarden localPosition = let position = worldTile plan localPosition draw position GardenSprite draw (offsetTile position 1 0) GardenSprite let drawWillow localPosition = let position = worldTile plan localPosition draw position WillowTopSprite draw (offsetTile position 0 1) WillowBaseSprite let drawSingleTile localPosition sprite = draw (worldTile plan localPosition) sprite let planProps = match mapGen with Some _ -> [] | None -> plan.Props for prop in planProps do match prop.Kind with | House -> drawHouse prop.Position | Bridge -> drawBridge prop.Position | BambooGrove -> drawBamboo prop.Position | VegetablePatch -> drawGarden prop.Position | WillowTree -> drawWillow prop.Position | ShrubPatch -> drawSingleTile prop.Position ShrubSprite | FlowerBush -> drawSingleTile prop.Position FlowerSprite | ReedCluster -> drawSingleTile prop.Position ReedSprite | StoneBench -> drawSingleTile prop.Position BenchSprite | RiverLantern -> drawSingleTile prop.Position LanternSprite | StoneLantern -> drawSingleTile prop.Position StoneLanternSprite | WaterVat -> drawSingleTile prop.Position WaterVatSprite | MarketStall -> drawSingleTile prop.Position StallSprite // Chimney smoke is a late pass so it always reads above the roof, the bamboo and // any other prop sharing its tile. The puff sits one row above the ridge centre // (offset 0,-3) and cycles through three frames driven by smokeFrameTick. let smokeSprite = match smokeFrameTick tick with | 0 -> SmokeSpriteA | 1 -> SmokeSpriteB | _ -> SmokeSpriteC for prop in planProps do match prop.Kind with | House -> draw (offsetTile (worldTile plan prop.Position) 0 -3) smokeSprite | _ -> () /// P20 证据用:把整张 MapGen 地图等比缩放到视口内绘制,返回绘制的瓦片数。 /// 仅用于 `LV_MAPGEN_SHOT` 整图取证,不参与正常游戏循环。 let drawMapFitted (spriteBatch: SpriteBatch) (textures: ArtTextures) (pixel: Texture2D) (viewportWidth: int) (viewportHeight: int) (map: MapGen.Result) (tint: XnaColor) (glowScale: float32) : int = let scale = min (float32 viewportWidth / float32 (max 1 (map.Width * Sim.tilePixels))) (float32 viewportHeight / float32 (max 1 (map.Height * Sim.tilePixels))) let cell = max 1 (int (float32 Sim.tilePixels * scale)) let offsetX = (viewportWidth - map.Width * cell) / 2 let offsetY = (viewportHeight - map.Height * cell) / 2 let tileX x = offsetX + x * cell let tileY y = offsetY + y * cell let spriteOf code = match code with | 1 -> WaterSprite | 2 -> StonePathSprite // P30 乡村空区:水田走静态水面、菜畦走菜地素材。 | 4 -> WaterSprite | 5 -> GardenSprite | _ -> GrassSprite for y in 0 .. map.Height - 1 do for x in 0 .. map.Width - 1 do let code = map.Tiles.[y * map.Width + x] spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle (spriteOf code), tint) drawMapStructures spriteBatch textures pixel tileX tileY cell map if glowScale > 0.0f then // 半径 4 tile 的柔和径向衰减;随 cell 缩放,整图/放大帧观感一致。 drawNightGlows spriteBatch pixel map (4 * cell) (int (150.0f * glowScale)) tileX tileY cell map.Width * map.Height /// P20 证据用:按相机做视口裁剪,只绘制可见瓦片(1:1 像素),返回实际绘制数。 let drawMapViewport (spriteBatch: SpriteBatch) (textures: ArtTextures) (pixel: Texture2D) (camera: XnaVector2) (viewportWidth: int) (viewportHeight: int) (map: MapGen.Result) (tick: int64) (tint: XnaColor) (glowScale: float32) : int = let x0, y0, x1, y1 = MapGen.visibleTileRange map.Width map.Height (int camera.X) (int camera.Y) viewportWidth viewportHeight let tileX x = x * Sim.tilePixels - int camera.X let tileY y = y * Sim.tilePixels - int camera.Y let spriteOf (x: int) (y: int) code = match code with | 1 -> match waterFrameTickAt tick x y with | 0 -> WaterSprite | 1 -> WaterSpriteB | _ -> WaterSpriteC | 2 -> StonePathSprite | 4 -> WaterSprite | 5 -> GardenSprite | _ -> GrassSprite let mutable drawn = 0 for y in y0 .. y1 do for x in x0 .. x1 do let code = map.Tiles.[y * map.Width + x] spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, Sim.tilePixels, Sim.tilePixels), tileSourceRectangle (spriteOf x y code), tint) drawn <- drawn + 1 drawMapStructures spriteBatch textures pixel tileX tileY Sim.tilePixels map if glowScale > 0.0f then drawNightGlows spriteBatch pixel map (4 * Sim.tilePixels) (int (150.0f * glowScale)) tileX tileY Sim.tilePixels drawn /// P30 证据用:把地图的任意 tile 矩形 [tileX0,tileY0,tilesW,tilesH] 以 cell px/tile 真实 /// 离屏渲染到 target(含民居/田块/树丛/桥/河岸装饰与可选夜光),返回绘制瓦片数。 /// 与 drawMapFitted/drawMapViewport 共用 drawMapStructures / drawNightGlows 纯函数。 let drawMapRegion (spriteBatch: SpriteBatch) (textures: ArtTextures) (pixel: Texture2D) (tileX0: int) (tileY0: int) (tilesW: int) (tilesH: int) (cell: int) (map: MapGen.Result) (tint: XnaColor) (glowScale: float32) : int = let tileX x = (x - tileX0) * cell let tileY y = (y - tileY0) * cell let spriteOf code = match code with | 1 -> WaterSprite | 2 -> StonePathSprite | 4 -> WaterSprite | 5 -> GardenSprite | _ -> GrassSprite let mutable drawn = 0 for y in tileY0 .. tileY0 + tilesH - 1 do for x in tileX0 .. tileX0 + tilesW - 1 do if x >= 0 && x < map.Width && y >= 0 && y < map.Height then let code = map.Tiles.[y * map.Width + x] spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle (spriteOf code), tint) drawn <- drawn + 1 drawMapStructures spriteBatch textures pixel tileX tileY cell map if glowScale > 0.0f then drawNightGlows spriteBatch pixel map (4 * cell) (int (150.0f * glowScale)) tileX tileY cell drawn let drawCharacter (spriteBatch: SpriteBatch) (textures: ArtTextures) (camera: XnaVector2) (position: Vec2) (spec: NpcSpriteSpec) (tint: XnaColor) = let destination = characterDestination position camera spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint) type InteriorLayout = { RoomX: int RoomY: int RoomWidth: int RoomHeight: int } let private interiorWallBandHeight = 64 let private floorCellSource = XnaRectangle(0, 64, Sim.tilePixels, Sim.tilePixels) let private wallCellSource = XnaRectangle(32, 64, Sim.tilePixels, Sim.tilePixels) let private rugSource = XnaRectangle(256, 32, 96, 64) let private chestSource = XnaRectangle(352, 60, 40, 36) let interiorSourceRectangle (kind: InteriorElementKind) : Microsoft.Xna.Framework.Rectangle = match kind with | Window -> XnaRectangle(64, 56, 48, 40) | Table -> XnaRectangle(112, 48, 64, 48) | Bed -> XnaRectangle(176, 0, 48, 96) | Screen -> XnaRectangle(224, 0, 32, 96) let interiorLayout (viewportWidth: int) (viewportHeight: int) : InteriorLayout = { RoomX = 40 RoomY = 40 RoomWidth = max 320 (viewportWidth - 80) RoomHeight = max 240 (viewportHeight - 80) } let interiorColumns (layout: InteriorLayout) : int = max 1 (layout.RoomWidth / Sim.tilePixels) let interiorRows (layout: InteriorLayout) : int = max 1 ((layout.RoomHeight - interiorWallBandHeight) / Sim.tilePixels) let interiorFloorY (layout: InteriorLayout) : int = layout.RoomY + interiorWallBandHeight let private clampInt (minimum: int) (maximum: int) (value: int) : int = if value < minimum then minimum elif value > maximum then maximum else value let interiorCharacterScreenPosition (layout: InteriorLayout) (worldPosition: Vec2) : PixelPosition = let sceneTile = sceneTileFromPixel { PixelPosition.X = worldPosition.X; Y = worldPosition.Y } let tileX = clampInt 1 (interiorColumns layout - 2) sceneTile.X let tileY = clampInt 1 (interiorRows layout - 2) sceneTile.Y let floorY = interiorFloorY layout { PixelPosition.X = float32 (layout.RoomX + tileX * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (floorY + (tileY + 1) * Sim.tilePixels) } let drawInterior (spriteBatch: SpriteBatch) (pixel: Texture2D) (textures: ArtTextures) (viewportWidth: int) (viewportHeight: int) (plan: InteriorPlan) (tint: XnaColor) = let layout = interiorLayout viewportWidth viewportHeight let floorY = interiorFloorY layout let columns = interiorColumns layout let rows = interiorRows layout let partialRows = (layout.RoomHeight - interiorWallBandHeight) % Sim.tilePixels let totalRows = 2 + rows + (if partialRows > 0 then 1 else 0) spriteBatch.Draw(pixel, XnaRectangle(0, 0, viewportWidth, viewportHeight), rgba 18 25 42 255) let drawCell (source: XnaRectangle) (screenX: int) (screenY: int) = let width = min Sim.tilePixels (layout.RoomX + layout.RoomWidth - screenX) let height = min Sim.tilePixels (layout.RoomY + layout.RoomHeight - screenY) if width > 0 && height > 0 then spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(screenX, screenY, width, height), source, tint) for row in 0 .. totalRows - 1 do for column in 0 .. columns - 1 do let screenX = layout.RoomX + column * Sim.tilePixels let screenY = layout.RoomY + row * Sim.tilePixels let source = if row < 2 then wallCellSource else floorCellSource drawCell source screenX screenY let rugX = layout.RoomX + (layout.RoomWidth - 96) / 2 let rugY = floorY + 96 spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(rugX, rugY, 96, 64), rugSource, tint) let elementDestination (element: InteriorElement) : XnaRectangle = let baseY = floorY + (element.Position.Y + 1) * Sim.tilePixels match element.Kind with | Window -> XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, layout.RoomY + 24, 48, 40) | Table -> XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 16, baseY - 48, 64, 48) | Bed -> XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 96, 48, 96) | Screen -> XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels, baseY - 96, 32, 96) for element in plan.Elements |> List.sortBy (fun element -> element.Position.Y, element.Position.X) do spriteBatch.Draw(textures.InteriorAtlas, elementDestination element, interiorSourceRectangle element.Kind, tint) let chestX = layout.RoomX + layout.RoomWidth - 44 let chestY = layout.RoomY + layout.RoomHeight - 36 spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(chestX, chestY, 40, 36), chestSource, tint) let drawInteriorCharacter (spriteBatch: SpriteBatch) (textures: ArtTextures) (viewportWidth: int) (viewportHeight: int) (position: Vec2) (spec: NpcSpriteSpec) (tint: XnaColor) = let layout = interiorLayout viewportWidth viewportHeight let screenPosition = interiorCharacterScreenPosition layout position let destination = XnaRectangle(int screenPosition.X - 16, int screenPosition.Y - 48, 32, 48) spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint)