From b94ae560c652e8a5e759630acc079d834673c95a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 16:33:26 +0800 Subject: feat(p40): 石板路连贯带 + 红灯笼入夜光 + LV_P40_SHOT 证据钩子 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VillageArt: cc0PavingTiles 纯函数(路径/门口/桥相连石板格),drawTerrainTile cc0 分支只铺连贯石板带(19)、孤立散布石板改画草地;fallback/大世界原样 - cc0RedLanternTiles 门口红灯笼(20),并入 nightLightTilesWith 复用 P14 预乘 alpha 光晕 - Game: LV_P40_SHOT 钩子拍 p40-day/night/boat(PrepareP40Shot/CaptureP40Shot) - 不写瓦片/不改可达性/不动 Sim.step --- src/LivingVillage.Desktop/Game.fs | 69 ++++++++++++++++++++++++++++++++ src/LivingVillage.Desktop/VillageArt.fs | 70 +++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 3 deletions(-) (limited to 'src/LivingVillage.Desktop') diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 7b1dfb1..8c0b8a1 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -295,6 +295,12 @@ type LivingVillageGame() as this = let mutable p39Step = 0 let mutable p39PendingName = "" let mutable p39Pending = false + // P40 证据钩子:LV_P40_SHOT=1 时在真实 256x192 生成器世界里拍 + // p40-day / p40-night / p40-boat-closeup(相机贴近乌篷船,便于 2x 特写)。 + let p40ShotMode = Environment.GetEnvironmentVariable("LV_P40_SHOT") = "1" + let mutable p40Step = 0 + let mutable p40PendingName = "" + let mutable p40Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -691,6 +697,59 @@ type LivingVillageGame() as this = p39PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y p39Step <- p39Step + 1 + /// P40 证据:真实生成器世界里拍昼 / 夜整帧与乌篷船近景。位置取自确定性摆放纯函数, + /// 含真实 tint 与 P14 光晕;相机贴近船体便于 2x NEAREST 特写。 + member private this.PrepareP40Shot () = + let placeCameraOn (tileX: int) (tileY: int) = + world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (tileX * Sim.tilePixels); Y = float32 (tileY * Sim.tilePixels) } } } + this.CenterCamera() + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + let dayHour = 12.0 + let nightHour = 23.5 + if not p40Pending then + match riverscapeMap with + | None -> this.Exit() + | Some map -> + let boat = + match VillageArt.cc0BoatTile map with + | Some (bx, by) -> (bx, by) + | None -> + match map.Bridges |> List.sortBy (fun (x, y) -> (x, y)) |> List.tryHead with + | Some (bx, by) -> (bx, by) + | None -> (map.Core.MinX, map.Core.MinY) + let boatShot = (fst boat + 1, snd boat) + match p40Step with + | 0 -> + setHour dayHour + placeCameraOn (fst boat) (snd boat) + p40PendingName <- "p40-day" + p40Pending <- true + | 1 -> + setHour nightHour + placeCameraOn (fst boat) (snd boat) + p40PendingName <- "p40-night" + p40Pending <- true + | 2 -> + setHour dayHour + placeCameraOn (fst boatShot) (snd boatShot) + p40PendingName <- "p40-boat" + p40Pending <- true + | _ -> + printfn "p40-shot=done" + this.Exit() + + /// P40 证据:与 P39 相同,在 `Draw` 末尾落盘当前后台缓冲。 + member private this.CaptureP40Shot () = + if p40Pending then + p40Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p40PendingName) + printfn "p40-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" + p40PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y + p40Step <- p40Step + 1 + /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = if not riverscapeTourWatchStarted then @@ -998,6 +1057,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP39Shot() + elif p40ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP40Shot() elif p37ShotMode then this.RunP37Shot() elif menu.Page = Playing then @@ -1379,6 +1446,8 @@ type LivingVillageGame() as this = this.CaptureP36PromptShot() if p39ShotMode && menu.Page = Playing then this.CaptureP39Shot() + if p40ShotMode && menu.Page = Playing then + this.CaptureP40Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index e08cde4..0427dea 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -556,7 +556,7 @@ module VillageArt = // 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 = 19 + let cc0TileCount = 21 let cc0TileIndexWaterA = 4 let cc0TileIndexWaterB = 5 let cc0TileIndexRoofRidge = 6 @@ -574,6 +574,10 @@ module VillageArt = 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 @@ -704,6 +708,47 @@ module VillageArt = && (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 = @@ -883,9 +928,12 @@ module VillageArt = /// 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 -> @@ -896,7 +944,7 @@ module VillageArt = else [ (building.DoorX, building.DoorY) ] (building.DoorX, building.DoorY) :: windowTiles) - lanterns @ roadLanterns @ houseLights |> List.distinct + lanterns @ roadLanterns @ redLanterns @ houseLights |> List.distinct /// 既有夜景灯光源(不含 P39 主路石灯笼),保持既有调用与断言语义不变。 let nightLightTiles (map: MapGen.Result) : (int * int) list = @@ -983,13 +1031,26 @@ module VillageArt = | 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 -> draw position StonePathSprite + | 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 @@ -1053,6 +1114,9 @@ module VillageArt = 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 -> () -- cgit v1.2.3