summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop')
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.pngbin60594 -> 63292 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyphs.txt10
-rw-r--r--src/LivingVillage.Desktop/ChineseText.fs10
-rw-r--r--src/LivingVillage.Desktop/CjkGlyphAtlas.fs2
-rw-r--r--src/LivingVillage.Desktop/Game.fs257
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs68
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs9
-rw-r--r--src/LivingVillage.Desktop/LaunchScreen.fs47
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj2
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs22
-rw-r--r--src/LivingVillage.Desktop/MenuState.fs12
11 files changed, 351 insertions, 88 deletions
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
index 598c2bc..af83cd7 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
index cdfc550..701200b 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
@@ -72,6 +72,7 @@
屋
工
已
+币
帮
年
庄
@@ -116,9 +117,11 @@
日
时
明
+昏
易
是
晚
+晨
景
暂
更
@@ -128,15 +131,18 @@
板
林
标
+格
档
桥
歉
+段
民
水
求
江
没
法
+清
渔
游
物
@@ -153,6 +159,7 @@
社
离
移
+空
竹
笑
粮
@@ -193,7 +200,9 @@
货
资
足
+跳
转
+过
近
返
还
@@ -223,6 +232,7 @@
食
饥
饿
+黄
黛
,
:
diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs
index 82418e6..52e124d 100644
--- a/src/LivingVillage.Desktop/ChineseText.fs
+++ b/src/LivingVillage.Desktop/ChineseText.fs
@@ -83,6 +83,10 @@ module ChineseText =
"开始新游戏"
"读取存档"
"退出"
+ "开始"
+ "读档"
+ "继续"
+ "ESC 或 空格 跳过"
"移动"
"互动"
"对话"
@@ -120,6 +124,12 @@ module ChineseText =
"E 进屋"
"E 交谈"
"时间"
+ "时段"
+ "倍速"
+ "体力"
+ "钱币"
+ "清晨"
+ "黄昏"
"互动已记录"
"1倍速"
"2倍速"
diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
index 07895c8..b6e6947 100644
--- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
+++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
@@ -11,7 +11,7 @@ module CjkGlyphAtlas =
// Keep this table sorted and stable so the generated PNG has a reproducible layout.
let private characterTable =
- "×一不业中主乐乡书买事于互交人今从价任传体作使保倍停入关内农准出到前力功务动助单卖南原友取受可同和回围圃土在墙备夜大天夫失好始存完家察对就层居屋工已帮年庄库应度开式当录待得心必态怒总恼意戏成或打找报拒择挑捕据接提播操收效敌数新方无日时明易是晚景暂更最有村板林标档桥歉民水求江没法渔游物状理瓦生用留白目石确社离移竹笑粮精系紧给绝继绪续置翻聊职能范获菜衅行观认讨议记讲论设话询说请读谅谈败货资足转近返还进迫退送选速道郎量金鉴钱门闭闲间闻附零需面项须食饥饿黛,:"
+ "×一不业中主乐乡书买事于互交人今从价任传体作使保倍停入关内农准出到前力功务动助单卖南原友取受可同和回围圃土在墙备夜大天夫失好始存完家察对就层居屋工已币帮年庄库应度开式当录待得心必态怒总恼意戏成或打找报拒择挑捕据接提播操收效敌数新方无日时明昏易是晚晨景暂更最有村板林标格档桥歉段民水求江没法清渔游物状理瓦生用留白目石确社离移空竹笑粮精系紧给绝继绪续置翻聊职能范获菜衅行观认讨议记讲论设话询说请读谅谈败货资足跳转过近返还进迫退送选速道郎量金鉴钱门闭闲间闻附零需面项须食饥饿黄黛,:"
let characters = characterTable.ToCharArray()
let rowCount = (characters.Length + columns - 1) / columns
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 1b9508b..3f3a1d8 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -97,6 +97,7 @@ type LivingVillageGame() as this =
let autoplay = (Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1") || (Environment.GetEnvironmentVariable("LV_MAP_TOUR") = "1")
let autoplayFlow = Environment.GetEnvironmentVariable("LV_AUTOPLAY_FLOW") = "1"
let sampleMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_SAMPLE") = "1"
+ let menuShotMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_MENU_SHOT") = "1"
let daylightAutoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY_DAYLIGHT") = "1"
let automated = autoplay || autoplayFlow
let modeName =
@@ -140,6 +141,10 @@ type LivingVillageGame() as this =
let mutable simulationControl = startSimulationControl
let mutable legacyStepsPerFrame = (if sampleMode then None else initialLegacyStepsPerFrame)
let mutable menu = MenuState.create startSimulationControl false
+ let mutable splashActive = true
+ let mutable splashFrame = 0L
+ let mutable menuEntranceFrame = 0L
+ let mutable menuShotFrame = 0L
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -313,6 +318,12 @@ type LivingVillageGame() as this =
printfn (if nextState.TimedOut then "sample result=timeout" else "sample result=ok")
this.Exit()
+ /// P16-FIX:菜单截图模式。停在开始界面,按确定性帧表向下切到「读档」,帧表见 MenuShotScript。
+ member private this.RunMenuShot () =
+ menuShotFrame <- menuShotFrame + 1L
+ if menuShotFrame = MenuShotScript.downFrame then
+ this.DispatchMenuInput Down
+
member private this.RunMapTour () =
let (tileX, tileY) = tourWaypoints.[tourIndex % tourWaypoints.Length]
let position : Vec2 =
@@ -481,34 +492,21 @@ type LivingVillageGame() as this =
printfn "flow=return-menu"
flowStep <- 2
| 2 when menu.Page = MainMenu ->
- moveDown 3
- this.DispatchMenuInput Confirm
- printfn "flow=settings=open"
- this.DispatchMenuInput Down
+ moveDown 1
this.DispatchMenuInput Confirm
- this.DispatchMenuInput Back
- printfn "flow=settings=back"
+ printfn "flow=load"
flowStep <- 3
- | 3 when menu.Page = MainMenu ->
- moveDown 4
- this.DispatchMenuInput Confirm
- printfn "flow=controls=open"
- flowStep <- 30
- | 30 ->
- flowHold <- flowHold + 1
- if flowHold >= 60 || menu.Page <> Controls then
- flowHold <- 0
- flowStep <- 31
- | 31 ->
+ | 3 ->
this.DispatchMenuInput Back
- printfn "flow=controls=back"
+ printfn "flow=back"
flowStep <- 4
| 4 when menu.Page = MainMenu ->
- this.DispatchMenuInput Confirm
this.SaveWorld "flow=save" |> ignore
flowStep <- 5
- | 5 when menu.Page = Playing ->
- this.LoadWorld "flow=load" |> ignore
+ | 5 when menu.Page = MainMenu ->
+ moveDown 1
+ this.DispatchMenuInput Confirm
+ printfn "flow=load-again"
flowStep <- 6
| 6 ->
this.OpenMainMenu()
@@ -540,12 +538,24 @@ type LivingVillageGame() as this =
let kb = Keyboard.GetState()
let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key))
let pressedAny (keys: Keys list) = keys |> List.exists pressed
- if autoplayFlow then
- this.RunAutoplayFlow()
- elif menu.Page = Playing then
- this.UpdatePlaying gameTime kb pressed pressedAny
+ if splashActive then
+ splashFrame <- splashFrame + 1L
+ let skip = LaunchScreen.skipRequested (pressed Keys.Escape) (pressed Keys.Space)
+ if skip || LaunchScreen.isComplete splashFrame then
+ splashActive <- false
+ menuEntranceFrame <- 0L
+ printfn (if skip then "splash=skip frame=%d" else "splash=done frame=%d") splashFrame
else
- this.UpdateMenu pressed
+ if autoplayFlow then
+ this.RunAutoplayFlow()
+ elif menu.Page = Playing then
+ this.UpdatePlaying gameTime kb pressed pressedAny
+ elif menuShotMode then
+ this.RunMenuShot()
+ else
+ this.UpdateMenu pressed
+ if menu.Page <> Playing then
+ menuEntranceFrame <- menuEntranceFrame + 1L
match pauseHelpControl with
| Some original when menu.Page = Playing ->
let _, restoredControl = MenuState.exitPauseHelp original menu
@@ -605,8 +615,37 @@ type LivingVillageGame() as this =
| None -> ()
spriteBatch.End()
+ /// 将当前后台缓冲存为 PNG(取证用;record 与菜单截图共用)。
+ member private this.SaveBackBuffer (path: string) =
+ let width = this.GraphicsDevice.PresentationParameters.BackBufferWidth
+ let height = this.GraphicsDevice.PresentationParameters.BackBufferHeight
+ let buffer = Array.zeroCreate<Color> (width * height)
+ this.GraphicsDevice.GetBackBufferData<Color>(buffer)
+ use texture = new Texture2D(this.GraphicsDevice, width, height)
+ texture.SetData(buffer)
+ use stream = System.IO.File.Create(path)
+ texture.SaveAsPng(stream, width, height)
+
+ /// P16-FIX:按 MenuShotScript 帧表落菜单/启动画面截图,拍完即退出。
+ member private this.CaptureMenuShot () =
+ let save name =
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name)
+ if splashActive then
+ if splashFrame = MenuShotScript.splashMidFrame then
+ save MenuShotScript.splashMidName
+ elif menuShotFrame = MenuShotScript.initialFrame then
+ save MenuShotScript.initialName
+ elif menuShotFrame = MenuShotScript.loadFrame then
+ save MenuShotScript.loadName
+ elif menuShotFrame = MenuShotScript.exitFrame then
+ printfn "menu-shot=done frames=%d" menuShotFrame
+ this.Exit()
+
override this.Draw(gameTime: GameTime) =
- if menu.Page <> Playing then
+ if splashActive then
+ this.DrawSplash()
+ elif menu.Page <> Playing then
this.DrawMenu()
elif relationView then
this.DrawRelationView()
@@ -616,18 +655,62 @@ type LivingVillageGame() as this =
if recordMode && sampleMode then
recordIndex <- recordIndex + 1
if recordIndex % recordEvery = 0 then
- let directory = recordDirectory
- System.IO.Directory.CreateDirectory directory |> ignore
- let width, height =
- this.GraphicsDevice.PresentationParameters.BackBufferWidth,
- this.GraphicsDevice.PresentationParameters.BackBufferHeight
- let buffer = Array.zeroCreate<Color> (width * height)
- this.GraphicsDevice.GetBackBufferData<Color>(buffer)
- use texture = new Texture2D(this.GraphicsDevice, width, height)
- texture.SetData(buffer)
- let path = sprintf "%s/rec-frame-%04d.png" directory recordIndex
- use stream = System.IO.File.Create(path)
- texture.SaveAsPng(stream, width, height)
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/rec-frame-%04d.png" recordDirectory recordIndex)
+ if menuShotMode then
+ this.CaptureMenuShot()
+
+ /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
+ member private this.DrawSplash() =
+ let viewport = this.GraphicsDevice.Viewport
+ let width = viewport.Width
+ let height = viewport.Height
+ let layout = TitleScreen.layout width height
+ let reveal = LaunchScreen.revealProgress splashFrame
+ let veil = LaunchScreen.veilAlpha splashFrame
+ let alpha (value: int) = int (float32 value * reveal)
+ spriteBatch.Begin()
+
+ // 分层天色:沿用标题画面色带,绝不整屏纯色或大黑块。
+ let bandCount = List.length layout.SkyBands
+ let bandHeight = height / bandCount
+ List.iteri (fun index (band: TitleScreen.ColorRgb) ->
+ let y0 = index * bandHeight
+ let y1 = if index = bandCount - 1 then height else y0 + bandHeight
+ spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands
+
+ // 屋脊剪影随暗幕退场而上浮就位。
+ let drop = int (28.0f * veil)
+ for x in 0 .. Sim.tilePixels .. width - 1 do
+ spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.RidgeY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (fst TitleScreen.roofSlots), Color.White)
+ spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White)
+
+ // 水面色带反射天色,随显现度提高而淡出。
+ let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - reveal)))
+ for row in layout.WaterRows do
+ for x in 0 .. Sim.tilePixels .. width - 1 do
+ spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor)
+
+ // 标题:淡入 + 上浮,先画深色投影再画亮面。
+ let titleRise = int (24.0f * (1.0f - reveal))
+ PixelText.draw spriteBatch pixel cjkAtlas (layout.TitleX + 4) (layout.TitleY + titleRise + 4) layout.TitleScale (Color(12, 20, 28, alpha 255)) LaunchScreen.title
+ PixelText.draw spriteBatch pixel cjkAtlas layout.TitleX (layout.TitleY + titleRise) layout.TitleScale (Color(235, 246, 232, alpha 255)) LaunchScreen.title
+
+ // 英文副标题保持同一 CJK/点阵字体路径。
+ let subtitleScale = 2
+ let subtitleWidth = FloatingPrompt.measureWidth LaunchScreen.subtitle subtitleScale
+ PixelText.draw spriteBatch pixel cjkAtlas ((width - subtitleWidth) / 2) (layout.TitleY + 78) subtitleScale (Color(180, 210, 214, alpha 255)) LaunchScreen.subtitle
+
+ // 跳过提示:轻微呼吸闪烁,落在底部独立窄条上(非整屏黑块)。
+ let blink = if (splashFrame / 20L) % 2L = 0L then 220 else 130
+ let hintScale = 2
+ let hintWidth = FloatingPrompt.measureWidth LaunchScreen.skipHint hintScale
+ let hintX = (width - hintWidth) / 2
+ let hintY = height - 56
+ spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 8 * hintScale + 16), Color(10, 16, 24, int (170.0f * (0.5f + 0.5f * reveal))))
+ spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 2), Color(92, 180, 190, alpha 220))
+ PixelText.draw spriteBatch pixel cjkAtlas hintX hintY hintScale (Color(220, 235, 225, alpha blink)) LaunchScreen.skipHint
+ spriteBatch.End()
member private this.DrawMenu() =
let viewport = this.GraphicsDevice.Viewport
@@ -678,7 +761,7 @@ type LivingVillageGame() as this =
spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands
// Deterministic 2s entrance: skyline settles from above as the progress unwinds.
- let entrance = TitleScreen.entranceProgress hudClock
+ let entrance = TitleScreen.entranceProgress menuEntranceFrame
let skylineDrop = int (36.0f * entrance)
List.iteri (fun column slot ->
let x = column * Sim.tilePixels
@@ -810,52 +893,72 @@ type LivingVillageGame() as this =
this.DrawM5Overlay()
member private this.DrawM5Overlay() =
+ this.DrawHudBar()
+ if m5View.Panel = WorldPanel then
+ this.DrawTransientStatus()
+ else
+ this.DrawPanel()
+
+ /// 顶部窄信息条:时段/时间/倍速/体力/钱币,半透明渐变 + 强调线,绝非大黑块。
+ member private this.DrawHudBar() =
let viewport = this.GraphicsDevice.Viewport
- let textScale = 2
- let lineHeight = 18
let profile = M6Presentation.profileAtTick world.Tick
- let timeLine = sprintf "时间 %s %s" (M6Presentation.dayNightLabel profile.Mode) (M6Presentation.clockLabel simulationControl)
- let persistentLines =
- timeLine :: (m5View.Prompt |> Option.toList)
- let dialogueLines =
- match m5View.Panel, m5View.Menu with
- | DialoguePanel, Some menu ->
- menu.Options
- |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (M5Interaction.intentLine intent))
- |> fun options -> [ sprintf "互动:村民 %d" (M5Interaction.menuNpcNumber menu) ] @ options
- | _ -> []
+ let palette = HudLayout.palette profile.Mode
+ let placed = HudLayout.layout (HudLayout.segments world simulationControl)
+ let barHeight = HudLayout.barHeight
+ let textY = (barHeight - 8 * HudLayout.textScale) / 2
+ spriteBatch.Begin()
+ // 竖向渐变:上半略亮、下半压暗,避免整条纯色。
+ spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop)
+ spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom)
+ spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent)
+ for (x, _, segment) in placed do
+ PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label
+ let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale
+ PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value
+ spriteBatch.End()
+
+ /// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。
+ member private this.DrawTransientStatus() =
+ let viewport = this.GraphicsDevice.Viewport
let statusView = { m5View with StatusTick = Some hudStatusAt }
- let statusLine = M5Interaction.transientStatusLine hudClock statusView |> Option.toList
- let allLines = persistentLines @ dialogueLines @ statusLine
- let cornerLines = M5Interaction.cornerStatusLines world
- let panelWidth = min 360 (max 1 (viewport.Width - 32))
- let maxLines = max 1 ((viewport.Height - 48) / lineHeight)
- let lines = allLines |> List.truncate maxLines
- let panelHeight = lines.Length * lineHeight + 24
+ match M5Interaction.transientStatusLine hudClock statusView with
+ | None -> ()
+ | Some line ->
+ let scale = 2
+ let width = FloatingPrompt.measureWidth line scale
+ let x = 16
+ let y = HudLayout.barHeight + HudLayout.accentHeight + 8
+ spriteBatch.Begin()
+ spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, width + 16, 8 * scale + 12), Color(16, 26, 32, 200))
+ spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, 2, 8 * scale + 12), Color(92, 180, 190))
+ PixelText.draw spriteBatch pixel cjkAtlas x y scale (Color(232, 242, 232)) line
+ spriteBatch.End()
+
+ /// 打开的面板(对话/需求/观察/年鉴/任务):底部左侧轻量渐变板,沿用既有 panelLines。
+ member private this.DrawPanel() =
+ let viewport = this.GraphicsDevice.Viewport
+ let textScale = 2
+ let lineHeight = 18
+ let lines = M5Interaction.panelLines world m5View
+ let panelWidth = min 380 (max 1 (viewport.Width - 32))
+ let maxLines = max 1 ((viewport.Height - HudLayout.barHeight - 48) / lineHeight)
+ let visibleLines = lines |> List.truncate maxLines
+ let panelHeight = visibleLines.Length * lineHeight + 26
let panelX = 16
- let panelY = max 16 (viewport.Height - panelHeight - 16)
- let panel = Rectangle(panelX, panelY, panelWidth, panelHeight)
+ let panelY = max (HudLayout.barHeight + 12) (viewport.Height - panelHeight - 16)
let innerX = panelX + 12
let innerWidth = max 1 (panelWidth - 24)
let maxChars = max 1 (innerWidth / (6 * textScale))
spriteBatch.Begin()
- spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 160))
+ // 竖向渐变板(半透明,非纯黑),顶部与左侧各一条强调线。
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(30, 44, 52, 190))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight / 2, panelWidth, panelHeight - panelHeight / 2), Color(16, 24, 30, 190))
spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 3), Color(92, 180, 190))
- lines
- |> List.iteri (fun index line ->
- let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line
- let color = if index = 0 then Color(220, 235, 220) else Color(190, 205, 210)
- PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 12 + index * lineHeight) textScale color visible)
-
- // Minimal top-right status strip: day counter + real money/energy values.
- let cornerWidth = 232
- let cornerHeight = List.length cornerLines * lineHeight + 20
- let cornerX = viewport.Width - cornerWidth - 16
- let cornerY = 16
- spriteBatch.Draw(pixel, Rectangle(cornerX, cornerY, cornerWidth, cornerHeight), Color(15, 19, 25, 150))
- spriteBatch.Draw(pixel, Rectangle(cornerX, cornerY, cornerWidth, 3), Color(92, 180, 190))
- cornerLines
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, 2, panelHeight), Color(92, 180, 190, 160))
+ visibleLines
|> List.iteri (fun index line ->
let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line
- PixelText.draw spriteBatch pixel cjkAtlas (cornerX + 10) (cornerY + 10 + index * lineHeight) textScale (Color(220, 235, 220)) visible)
+ let color = if index = 0 then Color(226, 238, 226) else Color(192, 208, 212)
+ PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 13 + index * lineHeight) textScale color visible)
spriteBatch.End()
diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs
new file mode 100644
index 0000000..d943cec
--- /dev/null
+++ b/src/LivingVillage.Desktop/HudLayout.fs
@@ -0,0 +1,68 @@
+namespace LivingVillage.Desktop
+
+open Microsoft.Xna.Framework
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+/// 顶部窄信息条 HUD 的纯布局/配色模型:只做确定性计算,实际绘制在 Game.fs。
+/// 常驻状态(时段/时间/倍速/体力/钱币)压进一条窄带,绝不用整屏纯色或大黑块。
+module HudLayout =
+
+ let barHeight = 36
+ let accentHeight = 3
+ let paddingX = 16
+ let segmentGap = 20
+ let textScale = 2
+
+ type Segment =
+ { Label: string
+ Value: string }
+
+ let segmentText (segment: Segment) : string = sprintf "%s %s" segment.Label segment.Value
+
+ /// 常驻状态片段,数值全部取自世界真实状态,绝不编造。
+ let segments (world: World) (control: SimulationControl) : Segment list =
+ let needs = world.Avatar.Mind.Needs
+ [ { Label = "时段"; Value = M6Presentation.timePeriodLabel world.Tick }
+ { Label = "时间"; Value = M6Presentation.clockTimeLabel world.Tick }
+ { Label = "倍速"; Value = M6Presentation.speedLabel control }
+ { Label = "体力"; Value = sprintf "%.0f" needs.Energy }
+ { Label = "钱币"; Value = sprintf "%.0f" needs.Money } ]
+
+ /// 段文本(含标签)的像素宽度。
+ let segmentWidth (segment: Segment) : int =
+ FloatingPrompt.measureWidth (segmentText segment) textScale
+
+ /// 左对齐排布,返回 (起点 x, 宽度, 分段);纯函数,便于布局回归断言。
+ let layout (segments: Segment list) : (int * int * Segment) list =
+ let _, placed =
+ segments
+ |> List.fold
+ (fun (x, acc) segment ->
+ let width = segmentWidth segment
+ x + width + segmentGap, (x, width, segment) :: acc)
+ (paddingX, [])
+ List.rev placed
+
+ type Palette =
+ { BarTop: Color
+ BarBottom: Color
+ Accent: Color
+ Label: Color
+ Value: Color }
+
+ /// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。
+ let palette (mode: M6DayNight) : Palette =
+ match mode with
+ | Day ->
+ { BarTop = Color(40, 56, 62, 200)
+ BarBottom = Color(22, 34, 40, 200)
+ Accent = Color(212, 168, 96)
+ Label = Color(206, 220, 208)
+ Value = Color(242, 248, 234) }
+ | Night ->
+ { BarTop = Color(22, 30, 48, 205)
+ BarBottom = Color(13, 19, 33, 205)
+ Accent = Color(120, 168, 196)
+ Label = Color(176, 196, 214)
+ Value = Color(228, 238, 246) }
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index b29cf6b..dd372ef 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -161,11 +161,10 @@ module FloatingPrompt =
Width: int
Height: int }
- // HUD exclusion geometry shared with the Game.fs renderer: bottom-left lightweight
- // panel band and top-right corner status strip.
+ // HUD exclusion geometry shared with the Game.fs renderer: full-width top status bar
+ // and the bottom-left lightweight panel band.
+ let hudTopBarHeight = 39
let hudBottomBandHeight = 160
- let hudTopRightWidth = 248
- let hudTopRightHeight = 72
/// Width of text as PixelText.draw advances it (scale applied, CJK 8px, latin 6px, space 4px).
let measureWidth (text: string) (scale: int) : int =
@@ -194,7 +193,7 @@ module FloatingPrompt =
let x =
if width + margin * 2 > viewportWidth then margin
else max margin (min (viewportWidth - margin - width) screenX)
- let topSafe = if x + width >= viewportWidth - hudTopRightWidth then hudTopRightHeight + margin else margin
+ let topSafe = hudTopBarHeight + margin
let bottomSafe = viewportHeight - hudBottomBandHeight - height - margin
let y = max topSafe (min bottomSafe screenY)
{ X = x; Y = y; Width = width; Height = height }
diff --git a/src/LivingVillage.Desktop/LaunchScreen.fs b/src/LivingVillage.Desktop/LaunchScreen.fs
new file mode 100644
index 0000000..0fecc6d
--- /dev/null
+++ b/src/LivingVillage.Desktop/LaunchScreen.fs
@@ -0,0 +1,47 @@
+namespace LivingVillage.Desktop
+
+/// 启动画面:固定的 60Hz 步进下,全部为帧计数的纯函数,确定性可复现。
+/// 标题淡入(revealProgress)、整屏暗幕退场(veilAlpha),Esc/空格可随时跳过。
+module LaunchScreen =
+
+ let durationFrames = 150L
+ let fadeInFrames = 45L
+ let title = "江南水乡"
+ let subtitle = "Living Village"
+ let skipHint = "ESC 或 空格 跳过"
+
+ let private clamp01 (value: float32) : float32 =
+ if value < 0.0f then 0.0f
+ elif value > 1.0f then 1.0f
+ else value
+
+ /// 0 = 完全未显示,1 = 完全显现。帧计数纯函数。
+ let revealProgress (frame: int64) : float32 =
+ if frame <= 0L then 0.0f
+ else clamp01 (float32 frame / float32 fadeInFrames)
+
+ /// 整屏暗幕:起手 1,随进程退到 0(1 -> 0)。
+ let veilAlpha (frame: int64) : float32 =
+ if frame <= 0L then 1.0f
+ elif frame >= durationFrames then 0.0f
+ else 1.0f - clamp01 (float32 frame / float32 durationFrames)
+
+ let isComplete (frame: int64) : bool = frame >= durationFrames
+
+ /// Esc 或 空格 触发跳过(调用方用“按下沿”结果,不做按键状态判定)。
+ let skipRequested (escapePressed: bool) (spacePressed: bool) : bool = escapePressed || spacePressed
+
+/// P16-FIX 证据补拍:开始界面菜单截图的确定性帧表(纯数据,Game 与回归测试共用)。
+/// 帧号均为 60Hz Update 计数:splash 淡入中 -> 菜单入场完成(初始高亮「开始」)-> 向下
+/// -> 高亮「读档」-> 退出。输出文件名不含目录与扩展名。
+module MenuShotScript =
+
+ let splashMidName = "frame-splash-mid-01"
+ let initialName = "menu-real-initial"
+ let loadName = "menu-real-load"
+
+ let splashMidFrame = 30L
+ let initialFrame = 130L
+ let downFrame = 140L
+ let loadFrame = 150L
+ let exitFrame = 170L
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index 093f7ae..d57036b 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -12,10 +12,12 @@
<Compile Include="VillageArt.fs" />
<Compile Include="ChineseText.fs" />
<Compile Include="CjkGlyphAtlas.fs" />
+ <Compile Include="LaunchScreen.fs" />
<Compile Include="MenuState.fs" />
<Compile Include="Interaction.fs" />
<Compile Include="SampleScript.fs" />
<Compile Include="M6Presentation.fs" />
+ <Compile Include="HudLayout.fs" />
<Compile Include="TitleScreen.fs" />
<Compile Include="Game.fs" />
diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs
index ea8fd2f..65300f0 100644
--- a/src/LivingVillage.Desktop/M6Presentation.fs
+++ b/src/LivingVillage.Desktop/M6Presentation.fs
@@ -63,3 +63,25 @@ module M6Presentation =
| OneX -> "1倍速"
| TwoX -> "2倍速"
| FiveX -> "5倍速"
+
+ /// 倍速标签(语义别名,HUD 用)。
+ let speedLabel (control: SimulationControl) : string = clockLabel control
+
+ let private hourOf (tick: int64) : int =
+ int ((tick / (Sim.ticksPerDay / 24L)) % 24L)
+
+ /// 24 小时制的模拟时钟(HH:MM),由 tick 纯函数导出。
+ let clockTimeLabel (tick: int64) : string =
+ let hourTicks = Sim.ticksPerDay / 24L
+ let hour = hourOf tick
+ let minute = int ((tick % hourTicks) * 60L / hourTicks)
+ sprintf "%02d:%02d" hour minute
+
+ /// 时段标签:清晨/白天/黄昏/夜晚,与实际 tick 一致(纯函数)。
+ let timePeriodLabel (tick: int64) : string =
+ let hour = hourOf tick
+ if hour < 5 then "夜晚"
+ elif hour < 8 then "清晨"
+ elif hour < 17 then "白天"
+ elif hour < 20 then "黄昏"
+ else "夜晚"
diff --git a/src/LivingVillage.Desktop/MenuState.fs b/src/LivingVillage.Desktop/MenuState.fs
index 619ac58..0155472 100644
--- a/src/LivingVillage.Desktop/MenuState.fs
+++ b/src/LivingVillage.Desktop/MenuState.fs
@@ -80,11 +80,13 @@ module MenuState =
Settings = simulationControl
Error = None }
+ /// P16 开始界面收敛为三项:开始(无存档)或继续(回到进行中的世界)/ 读档 / 退出。
+ /// 设置与操作说明不再占用开始界面,仍可在游戏内通过 F1/F2/F3 与 P 打开。
let items (state: MenuState) : MenuItem list =
if state.HasCurrentWorld then
- [ ContinueItem; NewGameItem; LoadItem; SettingsItem; ControlsItem; ExitItem ]
+ [ ContinueItem; LoadItem; ExitItem ]
else
- [ NewGameItem; LoadItem; SettingsItem; ControlsItem; ExitItem ]
+ [ NewGameItem; LoadItem; ExitItem ]
let settingsLabel setting : string =
match setting with
@@ -95,9 +97,9 @@ module MenuState =
let itemLabel item : string =
match item with
- | ContinueItem -> "继续游戏"
- | NewGameItem -> "开始新游戏"
- | LoadItem -> "读取存档"
+ | ContinueItem -> "继续"
+ | NewGameItem -> "开始"
+ | LoadItem -> "读档"
| SettingsItem -> "设置"
| ControlsItem -> "操作说明"
| ExitItem -> "退出"