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 FloaterAtlas: 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 // P34 structure slice: indices into cc0-tiles.png. Kenney Tiny Farm ships no // water / roof / wall / railing / door art, so indices 4-11 are self-drawn // (provenance in docs/素材来源.md), laid out by this fixed contract. let cc0TileCount = 21 let cc0TileIndexWaterA = 4 let cc0TileIndexWaterB = 5 let cc0TileIndexRoofRidge = 6 let cc0TileIndexRoofEave = 7 let cc0TileIndexWallWood = 8 let cc0TileIndexWallWindow = 9 let cc0TileIndexRailing = 10 let cc0TileIndexDoor = 11 // P39 appended Jiangnan elements (indices 12-18) keep every P34 index above // unchanged (backwards compatible); provenance in docs/素材来源.md. let cc0TileIndexBridgeCenter = 12 let cc0TileIndexBridgeEnd = 13 let cc0TileIndexBoatLeft = 14 let cc0TileIndexBoatRight = 15 let cc0TileIndexStoneLantern = 16 let cc0TileIndexStall = 17 let cc0TileIndexReeds = 18 // P40 appended elements (indices 19-20). P40 also redrew the boat (14/15) and // sharpened the stone lantern (16); indices 0-13 and 17-18 stay byte-identical. let cc0TileIndexPaving = 19 let cc0TileIndexRedLantern = 20 let cc0WaterFrameCount = 2 let cc0TileAtlasWidth = Sim.tilePixels * cc0TileCount 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 /// Deterministic 2-phase water per tile: `(tick/32 + x + 2y) mod 2`, so one frame /// shows both ripple phases across the river. Mirrors the 3-phase Jiangnan /// `waterFrameTickAt`, but only two self-drawn CC0 frames exist. let cc0WaterTileIndexAt (tick: int64) (tileX: int) (tileY: int) : int = let phase = int ((tick / 32L + int64 (tileX + 2 * tileY)) % 2L) if phase = 0 then cc0TileIndexWaterA else cc0TileIndexWaterB let cc0TileRect (index: int) : XnaRectangle = XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) // --- P39 CC0 element placement (deterministic pure functions) --------------- // All placements are functions of (map, seed) only — no clock, no randomness — // so the same seed yields byte-identical bridge/boat/lantern/stall/reed // positions across day and night frames. They only paint visuals; no tile is // written and no reachability is changed. /// Deterministic splitmix64 stream, mirroring MapGen's. Used to pick one boat /// tile and to thin the roadside lantern/stall set without touching the kernel. let private splitmix (state: uint64) : uint64 * uint64 = let nextState = state + 0x9E3779B97F4A7C15UL let mutable z = nextState z <- (z ^^^ (z >>> 30)) * 0xBF58476D1CE4E5B9UL z <- z ^^^ (z >>> 27) z <- z * 0x94D049BB133111EBUL z <- z ^^^ (z >>> 31) (nextState, z) let private hash2 (x: int) (y: int) (seed: uint64) : uint64 = snd (splitmix (seed ^^^ ((uint64 x * 0x4D3B9UL) + (uint64 y * 0x1D5UL)))) let private tileIsWater (map: MapGen.Result) (x: int) (y: int) : bool = x >= 0 && x < map.Width && y >= 0 && y < map.Height && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water /// P39 桥:每座桥的首列顶部一格铺 `bridge centre`,其后一格铺 `bridge end` /// (上桥台阶)。沿用既有 `MapGen.Bridges`(桥面瓦片),只叠加装饰,不改通行。 let cc0BridgeTiles (map: MapGen.Result) : (int * int * int) list = map.Bridges |> List.groupBy fst |> List.sortBy fst |> List.collect (fun (x, entries) -> let ys = entries |> List.map snd |> List.sort match ys with | top :: _ -> [ (x, top, cc0TileIndexBridgeCenter); (x, top - 1, cc0TileIndexBridgeEnd) ] | [] -> []) /// P39 乌篷船:在开阔水面确定性泊一艘 2 格船(tileX, tileY 为左半格)。 /// 候选为「该格及其右邻皆为水且非桥面/桥台」,优先泊在距某座桥最近处 /// (画面里桥、船、苇常同框),距离平局按 hash 裁决,保证同 seed 逐字节不变。 let cc0BoatTile (map: MapGen.Result) : (int * int) option = let bridgeSet = map.Bridges |> List.map (fun (x, y) -> y * map.Width + x) |> Set.ofList let isBridge x y = x >= 0 && x < map.Width && y >= 0 && y < map.Height && Set.contains (y * map.Width + x) bridgeSet let isOpen x y = tileIsWater map x y && tileIsWater map (x + 1) y && not (isBridge x y) && not (isBridge (x + 1) y) && not (isBridge (x - 1) y) let candidates = [ for y in 0 .. map.Height - 1 do for x in 1 .. map.Width - 3 do if isOpen x y then yield (x, y) ] let bridges = map.Bridges let distanceToNearestBridge (x: int) (y: int) = bridges |> List.map (fun (bx, by) -> abs (bx - x) + abs (by - y)) |> List.fold min System.Int32.MaxValue match candidates with | [] -> None | _ -> candidates |> List.sortBy (fun (x, y) -> distanceToNearestBridge x y, hash2 x y 0x39B0A7UL) |> List.tryHead /// P39 灯笼:沿主路每隔 `spacing` 列确定性取一格放石灯笼(避开水面)。 /// `MainRoadCenter.[x]` 给出行 y,主路足够长,同 seed 间距恒定。 /// 守卫 `spacing >= 2`:`spacing = 1` 时摊位偏移 `spacing/2 = 0` 会与灯笼同余而重叠, /// 故视为退化输入直接返回空集(见 `cc0StallTiles` 与 P39ArtTests 的奇偶/退化用例)。 let cc0LanternTiles (map: MapGen.Result) (spacing: int) : (int * int) list = if map.Width <= 0 || spacing < 2 then [] else [ for x in 1 .. map.Width - 2 do if x % spacing = 0 then let y = map.MainRoadCenter.[x] if not (tileIsWater map x y) then yield (x, y) ] /// P39 路摊:在主路灯笼之间确定性取一格放菜摊(与灯笼错开 `spacing/2`)。 /// 守卫 `spacing >= 2`:`spacing = 1` 时 `spacing/2 = 0` 与灯笼同余,会与灯笼重叠, /// 故退化输入返回空集;`spacing >= 2` 时灯笼余数 0、摊位余数 `spacing/2 ∈ [1, spacing-1]`, /// 两同余类天然互斥(奇偶皆成立,见单测)。 let cc0StallTiles (map: MapGen.Result) (spacing: int) : (int * int) list = if map.Width <= 0 || spacing < 2 then [] else [ for x in 1 .. map.Width - 2 do if x % spacing = spacing / 2 then let y = map.MainRoadCenter.[x] if not (tileIsWater map x y) then yield (x, y) ] /// P39 芦苇:沿岸线铺设——陆地格且其上下任一邻格为水(贴水岸),每隔 `spacing` 列取一格。 let cc0ReedTiles (map: MapGen.Result) (spacing: int) : (int * int) list = if map.Width <= 0 || spacing <= 0 then [] else [ for y in 1 .. map.Height - 2 do for x in 0 .. map.Width - 1 do if x % spacing = 0 && not (tileIsWater map x y) && (tileIsWater map x (y - 1) || tileIsWater map x (y + 1)) then yield (x, y) ] /// P40 石板路衔接:返回「与通行网络相连」的石板格索引集合。判据为纯几何: /// 该格是石板(code 2),且它本身或四邻属于 `map.Paths`(主路/桥引道/门口)或 /// `map.Bridges`(桥面)。稀疏的 `DecorativeStones` 散布石板因远离路网被排除, /// 于是 cc0 模式下石板只沿路径/门口/桥形成连贯带(孤立石板改画草地)。 /// 纯函数,只依赖 map(seed 内确定),不写瓦片、不改可达性。 let cc0PavingTiles (map: MapGen.Result) : Set = if map.Width <= 0 then Set.empty else let indexOf x y = y * map.Width + x let inMap x y = x >= 0 && x < map.Width && y >= 0 && y < map.Height let isStone x y = inMap x y && map.Tiles.[indexOf x y] = int MapGen.GroundTile.Stone let anchors = (map.Paths @ map.Bridges) |> List.map (fun (x, y) -> indexOf x y) |> Set.ofList let isAnchor x y = inMap x y && Set.contains (indexOf x y) anchors let connected x y = isAnchor x y || isAnchor (x - 1) y || isAnchor (x + 1) y || isAnchor x (y - 1) || isAnchor x (y + 1) [ for y in 0 .. map.Height - 1 do for x in 0 .. map.Width - 1 do if isStone x y && connected x y then yield indexOf x y ] |> Set.ofList /// P40 红灯笼:在每户民居门口确定性挂一盏红纸灯笼(门口 y,取门格)。 /// 与石灯笼形态区分:红纸圆身 + 金穗底座;位置为 (map) 纯函数,同 seed 不变。 /// 仅 cc0 模式绘制,且并入 `nightLightTilesWith` 的既有 P14 预乘 alpha 光晕路径。 let cc0RedLanternTiles (map: MapGen.Result) : (int * int) list = if map.Width <= 0 then [] else (map.Buildings @ map.Farmhouses) |> List.map (fun building -> (building.DoorX, building.DoorY)) |> List.filter (fun (x, y) -> x >= 0 && x < map.Width && y >= 0 && y < map.Height) |> List.distinct /// 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 // P45:角色改用原创点阵像素代码内嵌生成的动画图集(5 变体 x 4 向 x 6 帧), // 不再采样任何外部/占位角色素材;尺寸与既有 characterSourceRectangle 完全一致。 CharacterAtlas = CharacterArt.buildAtlas device InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight // P47:漂浮小元素(鸭/叶)改用原创点阵像素代码内嵌生成的图集(2 kind x 2 frame)。 FloaterAtlas = FloaterArt.buildAtlas device 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:散落农舍的门窗暖光一并计入。 /// P39:`includeRoadLanterns` 为真时把主路石灯笼(`cc0LanternTiles`)计入,使夜帧灯笼 /// 光晕复用同一光晕路径;仅 cc0 模式为真,故 fallback(江南程序图集)不受影响。 /// P40:同一开关下把门口红灯笼(`cc0RedLanternTiles`)也计入;红灯笼与石灯笼复用 /// 同一条 P14 预乘 alpha 径向光晕路径,不新增光照系统。 let nightLightTilesWith (includeRoadLanterns: bool) (map: MapGen.Result) : (int * int) list = let lanterns = bridgeLanternTiles map let roadLanterns = if includeRoadLanterns then cc0LanternTiles map 12 else [] let redLanterns = if includeRoadLanterns then cc0RedLanternTiles map else [] 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 @ roadLanterns @ redLanterns @ houseLights |> List.distinct /// 既有夜景灯光源(不含 P39 主路石灯笼),保持既有调用与断言语义不变。 let nightLightTiles (map: MapGen.Result) : (int * int) list = nightLightTilesWith false map /// 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) (includeRoadLanterns: bool) : unit = if peakAlpha > 0 && radiusPixels > 0 then let half = cell / 2 for (lightX, lightY) in nightLightTilesWith includeRoadLanterns 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 cc0BoatNightTintScale = 0.7f let drawCc0BoatOverlay (spriteBatch: SpriteBatch) (textures: ArtTextures) (camera: XnaVector2) (map: MapGen.Result) (tint: XnaColor) = let night = XnaColor( int (float32 tint.R * cc0BoatNightTintScale), int (float32 tint.G * cc0BoatNightTintScale), int (float32 tint.B * cc0BoatNightTintScale), int tint.A) match textures.Cc0TileAtlas with | Some cc0 -> match cc0BoatTile map with | Some (boatX, boatY) -> drawCc0Tile spriteBatch cc0 night (cc0TileRect cc0TileIndexBoatLeft) (boatX * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y) drawCc0Tile spriteBatch cc0 night (cc0TileRect cc0TileIndexBoatRight) ((boatX + 1) * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y) | None -> () | None -> () 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 // P34: with the CC0 pack on, individual sample structures draw from the // real/self-drawn cc0-tiles atlas; otherwise every draw falls back to jiangnan. let drawCc0At (position: TilePosition) (index: int) = match textures.Cc0TileAtlas with | Some cc0 -> drawCc0Tile spriteBatch cc0 tint (cc0TileRect index) (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y) | None -> () // 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 // P34 water: the self-drawn CC0 atlas only has two frames, so it picks the // 2-phase index; the jiangnan atlas keeps its 3-phase ripple. let drawWater (position: TilePosition) = match textures.Cc0TileAtlas with | Some _ -> drawCc0At position (cc0WaterTileIndexAt tick position.X position.Y) | None -> draw position (waterSpriteAt position) // P34 structures: real/self-drawn roof, wall, window and door cells replace // the jiangnan house tiles only when the CC0 pack is on. let houseSpriteAt (position: TilePosition) (sprite: TileSprite) = let cc0Index = match textures.Cc0TileAtlas with | Some _ -> match sprite with | RoofRidgeSprite -> Some cc0TileIndexRoofRidge | RoofEaveSprite -> Some cc0TileIndexRoofEave | WallWoodSprite -> Some cc0TileIndexWallWood | WallWindowSprite -> Some cc0TileIndexWallWindow | DoorSprite -> Some cc0TileIndexDoor | _ -> None | None -> None match cc0Index with | Some index -> drawCc0At position index | None -> draw position sprite // P40 石板路衔接:cc0 模式下只在「与路网/门口/桥相连」的石板格铺连贯石板带, // 稀疏孤立装饰石板改画草地,于是场地上不再出现游离石板。fallback(无 cc0 图集) // 与无 mapGen 的大世界保持原样,故既有输出逐字节不变。 let cc0PavingSet = match mapGen, textures.Cc0TileAtlas with | Some map, Some _ -> Some (map.Width, cc0PavingTiles map) | _ -> None // 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 -> drawWater position | 2 -> match cc0PavingSet with | Some (mapWidth, paving) when Set.contains (position.Y * mapWidth + position.X) paving -> drawCc0At position cc0TileIndexPaving | Some _ -> draw position GrassSprite | None -> draw position StonePathSprite // P30 乡村空区:水田(4)走水波动画、菜畦(5)走菜地素材;均为成熟 tile。 // P34:水田/水网在 CC0 包下同样走自绘 2 相位水面。 | 4 -> drawWater position | 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 // P39 中式元素补绘:仅在 CC0 包开启时叠加到生成器世界(自绘 12-18 格)。 // 桥/船/灯/摊/苇位置为 (map, seed) 纯函数,只叠加视觉、不写瓦片/不改可达性。 match textures.Cc0TileAtlas with | Some _ -> let drawCc0Map (tileX: int) (tileY: int) (index: int) = drawCc0Tile spriteBatch textures.Cc0TileAtlas.Value tint (cc0TileRect index) (mapTileX tileX) (mapTileY tileY) for (bx, by, index) in cc0BridgeTiles map do drawCc0Map bx by index match cc0BoatTile map with | Some (boatX, boatY) -> drawCc0Map boatX boatY cc0TileIndexBoatLeft drawCc0Map (boatX + 1) boatY cc0TileIndexBoatRight | None -> () for (lanternX, lanternY) in cc0LanternTiles map 12 do drawCc0Map lanternX lanternY cc0TileIndexStoneLantern for (stallX, stallY) in cc0StallTiles map 12 do drawCc0Map stallX stallY cc0TileIndexStall for (reedX, reedY) in cc0ReedTiles map 4 do drawCc0Map reedX reedY cc0TileIndexReeds // P40 红灯笼:门口挂一盏,与主路石灯笼形态区分;夜光晕走同一 P14 路径。 for (lanternRedX, lanternRedY) in cc0RedLanternTiles map do drawCc0Map lanternRedX lanternRedY cc0TileIndexRedLantern | None -> () | 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 -> drawWater 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 houseSpriteAt (offsetTile position dx -2) RoofRidgeSprite houseSpriteAt (offsetTile position dx -1) RoofEaveSprite houseSpriteAt (offsetTile position -2 0) WallWoodSprite houseSpriteAt (offsetTile position -1 0) WallWindowSprite houseSpriteAt (offsetTile position 0 0) DoorSprite houseSpriteAt (offsetTile position 1 0) WallWoodSprite let drawBridge localPosition = let position = worldTile plan localPosition let bridgeTile p = match textures.Cc0TileAtlas with | Some _ -> drawCc0At p cc0TileIndexRailing | None -> draw p BridgeSprite bridgeTile position bridgeTile (offsetTile position 0 1) bridgeTile (offsetTile position 0 2) 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 false 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 false 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 false 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)