From 4971a2ada6db9a37807bf46a29dbea3d9edbf06a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 05:42:36 +0800 Subject: p64: A3 样板三处视觉雕琢(俯瞰上半补种/桥栏杆与护岸石/内景家具)+ 证据 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P64EvidenceTests.fs | 56 ++++++++++++ src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 8 +- .../Assets/jiangnan-interior.png | Bin 1613 -> 1906 bytes .../Assets/jiangnan-world.png | Bin 4492 -> 4512 bytes src/LivingVillage.Desktop/Game.fs | 98 +++++++++++++++++++++ src/LivingVillage.Desktop/VillageArt.fs | 65 +++++++++++++- 7 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a6a05e6..9f60cce 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -36,6 +36,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs b/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs new file mode 100644 index 0000000..9000a04 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs @@ -0,0 +1,56 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillagePresentation + +/// P64(A3 成熟度):样板区三处视觉缺口的可复现约束。 +/// 1. 俯瞰上半部(local y 0..6)不再是空场:足量中密度补种且种类多样。 +/// 2. 桥在既有位置加可读性细节:4 块护岸石分列桥两侧水线。 +/// 3. 内景下半部补入椅/凳/床尾凳/壶 4 件家具。 +[] +type P64EvidenceTests () = + + let samplePlan () = VillageArt.sampleRenderPlan () + + [] + member _.OverviewUpperBandIsDenselyPopulated () = + let plan = samplePlan () + let upper = + plan.Props + |> List.filter (fun prop -> prop.Kind <> VillageArt.House && prop.Position.Y >= 0 && prop.Position.Y <= 6) + let kinds = upper |> List.map (fun prop -> prop.Kind) |> List.distinct + Assert.IsTrue(upper.Length >= 8, sprintf "上半部补种不足:%d" upper.Length) + Assert.IsTrue(kinds.Length >= 4, sprintf "上半部地物种类不足:%A" kinds) + for kind in [ VillageArt.VegetablePatch; VillageArt.BambooGrove; VillageArt.ShrubPatch; VillageArt.FlowerBush ] do + Assert.IsTrue(kinds |> List.contains kind, sprintf "上半部缺少 %A" kind) + // 北侧田埂小径贯通 x0..19。 + for x in 0 .. 19 do + Assert.IsTrue( + plan.Grounds |> List.contains ({ X = x; Y = 0 }, VillageArt.StonePath), + sprintf "北侧小径缺格 (%d,0)" x) + + [] + member _.BridgeAbutmentsFlankTheSingleSampleBridge () = + let plan = samplePlan () + let abutments = VillageArt.sampleBridgeAbutmentTiles plan + Assert.AreEqual(4, abutments.Length) + Assert.AreEqual(4, (abutments |> List.distinct |> List.length)) + let bridgeTiles = + plan.Props + |> List.filter (fun prop -> prop.Kind = VillageArt.Bridge) + |> List.collect (fun prop -> + [ for dy in 0 .. 2 -> ({ X = prop.Position.X; Y = prop.Position.Y + dy } : TilePosition) ]) + |> List.map (VillageArt.worldTile plan) + for tile in abutments do + Assert.IsFalse(bridgeTiles |> List.contains tile, "护岸石不得压在桥面上") + + [] + member _.InteriorLowerBandGainsFurniture () = + let plan = samplePlan () + let kinds = plan.Interior.Elements |> List.map (fun element -> element.Kind) + Assert.AreEqual(8, plan.Interior.Elements.Length) + for kind in [ VillageArt.Chair; VillageArt.Stool; VillageArt.BedFoot; VillageArt.Teapot ] do + Assert.IsTrue(kinds |> List.contains kind, sprintf "内景缺少 %A" kind) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 55de3c8..da81f0e 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -460,7 +460,8 @@ type PrototypeTests () = Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.Water)) Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.StonePath)) Assert.IsTrue(first.Props |> List.exists (fun prop -> prop.Kind = VillageArt.Bridge)) - Assert.AreEqual(4, first.Interior.Elements.Length) + // P64 补入椅/凳/床尾凳/壶 4 件家具 → 内景元素 4 → 8。 + Assert.AreEqual(8, first.Interior.Elements.Length) [] member _.HouseFacadeCoversRoofEaveAndWalledDoorRow () = @@ -474,8 +475,9 @@ type PrototypeTests () = Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx) // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds - // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17). - Assert.AreEqual(37, footprint.Count) + // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17) + // + P64 上半部补种 4 菜畦(2格)+2 竹丛(3格)+2 灌木+2 花丛 = 37 + 18 = 55。 + Assert.AreEqual(55, footprint.Count) [] member _.VegetationAndRiversidePropsDecorateTheScene () = diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png index d23f6b7..027d036 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png and b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png differ diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png index b67dd01..90d21e3 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-world.png and b/src/LivingVillage.Desktop/Assets/jiangnan-world.png differ diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index e5f2c11..6b52225 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -331,6 +331,12 @@ type LivingVillageGame() as this = let mutable p62PendingName = "" let mutable p62Pending = false let mutable p62MoveFrames = 0 + // P64 证据钩子(A3 成熟度):俯瞰/桥近景/内景三帧,内景走真实 MoveTo + Interact。 + let p64ShotMode = Environment.GetEnvironmentVariable("LV_P64_SHOT") = "1" + let mutable p64Step = 0 + let mutable p64PendingName = "" + let mutable p64Pending = false + let mutable p64MoveFrames = 0 // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。 let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1" let mutable p45Step = 0 @@ -1070,6 +1076,88 @@ type LivingVillageGame() as this = p62PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode p62Step <- p62Step + 1 + /// P64 证据(A3 成熟度):样板区俯瞰 / 桥近景 / 内景三帧。 + /// 内景沿用 P62 的真实 MoveTo + Interact 进门路径;桥为既有位置,只增强可读性。 + member private this.PrepareP64Shot () = + let tileCenter (t: TilePosition) : Vec2 = + { X = float32 (t.X * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (t.Y * Sim.tilePixels + Sim.tilePixels / 2) } + let sceneCenter localX localY = + tileCenter (worldTileForSceneTile { X = localX; Y = localY }) + let home = VillagePresentation.scene.Homes |> List.head + let doorCenter = sceneCenter home.Door.X home.Door.Y + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + if not p64Pending then + match p64Step with + | 0 -> + setHour 12.0 + // 俯瞰:整村居中,上半部补种与桥、民居同框。 + world <- { world with Avatar = { world.Avatar with Pos = sceneCenter 6 3 } } + this.CenterCamera() + let plan = VillageArt.sampleRenderPlan () + let upper = + plan.Props + |> List.filter (fun prop -> prop.Kind <> VillageArt.House && prop.Position.Y >= 0 && prop.Position.Y <= 6) + let upperKinds = upper |> List.map (fun prop -> prop.Kind) |> List.distinct |> List.length + let bridgeProps = plan.Props |> List.filter (fun prop -> prop.Kind = VillageArt.Bridge) |> List.length + let abutments = VillageArt.sampleBridgeAbutmentTiles plan |> List.length + printfn + "p64-plan upper_band_props=%d upper_band_kinds=%d bridge_props=%d bridge_foot_tiles=3 bridge_abutments=%d interior_elements=%d" + upper.Length + upperKinds + bridgeProps + abutments + plan.Interior.Elements.Length + p64PendingName <- "p64-overview" + p64Pending <- true + | 1 -> + setHour 12.0 + // 桥近景:角色立于桥北岸,相机居中,桥面栏杆/护岸石入画。 + world <- { world with Avatar = { world.Avatar with Pos = sceneCenter 6 10 } } + this.CenterCamera() + p64PendingName <- "p64-bridge" + p64Pending <- true + | 2 -> + // 真实走入:沿真实 Sim.step 向门口移动,抵达后真实 Interact 进门。 + let dx = doorCenter.X - world.Avatar.Pos.X + let dy = doorCenter.Y - world.Avatar.Pos.Y + if dx * dx + dy * dy <= 12.0f * 12.0f then + let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View + world <- nextWorld + m5View <- nextView + match m5View.HomeMode with + | Inside _ -> + p64PendingName <- "p64-interior" + p64Pending <- true + | Outside -> + p64MoveFrames <- p64MoveFrames + 1 + else + let length = sqrt (dx * dx + dy * dy) + let input = + if length > 0.001f then { MoveX = dx / length; MoveY = dy / length } + else { MoveX = 0.0f; MoveY = 0.0f } + world <- Sim.step { Input = input } world + p64MoveFrames <- p64MoveFrames + 1 + if p64MoveFrames > 900 then + printfn "p64-shot=move-timeout frames=%d home=%A" p64MoveFrames m5View.HomeMode + this.Exit() + | _ -> + printfn "p64-shot=done" + this.Exit() + + /// P64 证据:落盘并打印帧名/相机/内景模式,供 analyze 核验。 + member private this.CaptureP64Shot () = + if p64Pending then + p64Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p64PendingName) + printfn + "p64-shot=%s tick=%d avatar=(%.0f,%.0f) camera=(%.0f,%.0f) home=%A" + p64PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode + p64Step <- p64Step + 1 + member private this.PrepareP45Shot () = let baseTick = M6Presentation.resolveStartTick (Some 12.0) false let setTick (tick: int64) = @@ -2067,6 +2155,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP62Shot() + elif p64ShotMode 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.PrepareP64Shot() elif p45ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm @@ -2541,6 +2637,8 @@ type LivingVillageGame() as this = this.CaptureP61Shot() if p62ShotMode && menu.Page = Playing then this.CaptureP62Shot() + if p64ShotMode && menu.Page = Playing then + this.CaptureP64Shot() if p45ShotMode && menu.Page = Playing then this.CaptureP45Shot() if p46ShotMode then diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 3dbb8d4..031b577 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -58,6 +58,11 @@ module VillageArt = | Table | Bed | Screen + // P64 内景下半部家具补件(椅/凳/床尾凳/壶)。 + | Chair + | Stool + | BedFoot + | Teapot type InteriorElement = { Kind: InteriorElementKind @@ -210,7 +215,10 @@ module VillageArt = { Origin = position 25 18 Grounds = - [ ground StonePath 3 5 + // P64 北侧田埂小径:衔接上方补种的菜畦/竹丛,填充样板区上半部空旷。 + [ for x in 0 .. 19 do + ground StonePath x 0 + ground StonePath 3 5 ground StonePath 4 5 ground StonePath 5 5 ground StonePath 6 5 @@ -256,13 +264,30 @@ module VillageArt = renderProp StoneLantern 4 14 renderProp StoneLantern 16 14 renderProp WaterVat 12 11 - renderProp MarketStall 3 11 ] + renderProp MarketStall 3 11 + // P64 样板区上半部(local y 0..6)中密度补种:菜畦/竹丛/灌木/花丛, + // 全部复用既有 RenderPropKind 与低饱和配色,非几何占位。 + renderProp VegetablePatch 1 1 + renderProp VegetablePatch 10 2 + renderProp VegetablePatch 14 1 + renderProp VegetablePatch 16 4 + renderProp BambooGrove 8 3 + renderProp BambooGrove 18 2 + renderProp ShrubPatch 0 5 + renderProp ShrubPatch 12 5 + renderProp FlowerBush 2 3 + renderProp FlowerBush 6 1 ] Interior = { Elements = [ interiorElement Window 2 2 interiorElement Table 4 4 interiorElement Bed 7 3 - interiorElement Screen 7 6 ] } } + interiorElement Screen 7 6 + // P64 内景下半部家具补件:椅/凳/床尾凳/柜旁壶。 + interiorElement BedFoot 5 4 + interiorElement Chair 2 5 + interiorElement Stool 6 5 + interiorElement Teapot 4 5 ] } } let worldTile (plan: JiangnanRenderPlan) (localPosition: TilePosition) : TilePosition = { X = plan.Origin.X + localPosition.X @@ -294,6 +319,19 @@ module VillageArt = | MarketStall -> [ position x y ]) |> many + /// P64 河埠/护岸石偏移(相对桥首格):桥两侧水线各铺一块,强化桥与河岸的衔接。 + /// 只做视觉叠加,不写入 footprint、不改瓦片/通行。 + let bridgeAbutmentOffsets : (int * int) list = + [ (-1, 1); (1, 1); (-1, 2); (1, 2) ] + + /// 样板桥两侧护岸石的世界坐标(纯函数,供绘制与回归共用)。 + let sampleBridgeAbutmentTiles (plan: JiangnanRenderPlan) : TilePosition list = + plan.Props + |> List.filter (fun prop -> prop.Kind = Bridge) + |> List.collect (fun prop -> + bridgeAbutmentOffsets + |> List.map (fun (dx, dy) -> worldTile plan { X = prop.Position.X + dx; Y = prop.Position.Y + dy })) + let private positiveModulo divisor value = let remainder = value % divisor if remainder < 0 then remainder + divisor else remainder @@ -1175,6 +1213,13 @@ module VillageArt = match textures.Cc0TileAtlas with | Some _ -> drawCc0At p cc0TileIndexRailing | None -> draw p BridgeSprite + // P64 河埠/护岸石:仅江南图集下于桥两侧水线补石,增强桥的可读性; + // cc0 模式保持原样,不改位置/通行。 + match textures.Cc0TileAtlas with + | Some _ -> () + | None -> + for (dx, dy) in bridgeAbutmentOffsets do + draw (offsetTile position dx dy) RevetmentStoneSprite bridgeTile position bridgeTile (offsetTile position 0 1) bridgeTile (offsetTile position 0 2) @@ -1368,6 +1413,11 @@ module VillageArt = | Table -> XnaRectangle(112, 48, 64, 48) | Bed -> XnaRectangle(176, 0, 48, 96) | Screen -> XnaRectangle(224, 0, 32, 96) + // P64 补件:坐标与 scripts/make-jiangnan-art.py 的绘制区间一一对应。 + | Chair -> XnaRectangle(4, 8, 28, 44) + | Stool -> XnaRectangle(36, 14, 24, 22) + | BedFoot -> XnaRectangle(262, 4, 56, 18) + | Teapot -> XnaRectangle(330, 6, 20, 16) let interiorLayout (viewportWidth: int) (viewportHeight: int) : InteriorLayout = { RoomX = 40 @@ -1442,6 +1492,15 @@ module VillageArt = 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) + // P64 补件按其像素体量落位(低矮家具贴近地面线)。 + | Chair -> + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 44, 28, 44) + | Stool -> + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 22, 24, 22) + | BedFoot -> + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 16, baseY - 14, 56, 18) + | Teapot -> + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 16, 20, 16) 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) -- cgit v1.2.3