summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 21:53:53 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 21:53:53 +0800
commitb5ffb35fc0824a297ff88297390310acb6c45335 (patch)
tree08ef71069e75719e2c92ddd538ec7d58866752f2
parent3777d74423bcc941d48ca69bdf6bbc912a80ba57 (diff)
downloadliving-village-b5ffb35fc0824a297ff88297390310acb6c45335.tar.gz
fix(desktop): 灯笼光晕按自身 alpha 预乘并改径向层叠,消除夜间纯白方块
- 新增 VillageArt.premultiply:MonoGame SpriteBatch 默认预乘混合,直传 Color(r,g,b,a) 会直接加 rgb 导致多层暖色饱和成 75x75 纯白块 - lanternGlowLayers 用同心圆 pixel run 做径向衰减,逐层预乘,只在 RiverLantern 实体处绘制 - 水面相位改 waterFrameTickAt=(tick/32+x+2y)%3,同一帧相邻砖相位不同 - 炊烟改为 drawWorld 末尾独立一遍,位置 offsetTile 0 -3,画在屋瓦/竹子之上 - 角色脚底对齐 characterFeetOffset=16 并加预乘椭圆接触阴影 - 补全 Interaction.itemName 的 ItemKind 匹配(否则非 Food 交易会崩溃且告警)
-rw-r--r--src/LivingVillage.Desktop/Game.fs20
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs3
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs115
3 files changed, 110 insertions, 28 deletions
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index c91fe6e..6918e00 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -741,28 +741,28 @@ type LivingVillageGame() as this =
profile.WorldTint
// Warm lantern glows strengthen as night falls (pure function of tick phase).
- // Soft radial falloff made of wide translucent rings; no hot white core,
- // the lantern sprite itself stays readable underneath.
+ // Concentric premultiplied circles fall off radially from each lantern tile, so
+ // the core stays a soft warm disc instead of saturating into a white block, and
+ // no glow is painted where there is no lantern entity.
if profile.LanternGlow > 0.3f then
let glow = profile.LanternGlow
- let haloAlpha = int (34.0f * glow)
- let ringAlpha = int (18.0f * glow)
+ let peakAlpha = int (140.0f * glow)
for prop in renderPlan.Props do
match prop.Kind with
| VillageArt.RenderPropKind.RiverLantern ->
let position = VillageArt.worldTile renderPlan prop.Position
- let screenX = position.X * Sim.tilePixels - int camera.X + Sim.tilePixels / 2
- let screenY = position.Y * Sim.tilePixels - int camera.Y - Sim.tilePixels / 2
- spriteBatch.Draw(pixel, Rectangle(screenX - 30, screenY - 30, 76, 76), Color(252, 196, 128, int (float32 ringAlpha * 0.5f)))
- spriteBatch.Draw(pixel, Rectangle(screenX - 24, screenY - 24, 64, 64), Color(252, 206, 148, ringAlpha))
- spriteBatch.Draw(pixel, Rectangle(screenX - 18, screenY - 18, 52, 52), Color(252, 216, 148, haloAlpha))
- spriteBatch.Draw(pixel, Rectangle(screenX - 12, screenY - 12, 40, 40), Color(255, 226, 168, haloAlpha))
+ let centerX = position.X * Sim.tilePixels - int camera.X + Sim.tilePixels / 2
+ let centerY = position.Y * Sim.tilePixels - int camera.Y + Sim.tilePixels / 2
+ for (rect, color) in VillageArt.lanternGlowLayers centerX centerY 44 6 255 214 150 peakAlpha do
+ spriteBatch.Draw(pixel, rect, color)
| _ -> ()
+ VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos
let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick
VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint
for npc in world.Npcs do
+ VillageArt.drawGroundShadow spriteBatch pixel camera npc.Pos
let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick
VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint
spriteBatch.End()
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 95aa84c..b29cf6b 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -256,6 +256,9 @@ module M5Interaction =
let private itemName (item: ItemKind) : string =
match item with
| Food -> "食物"
+ | Fish -> "鱼"
+ | Spice -> "香料"
+ | Scroll -> "卷轴"
let private responseName (response: DialogueResponse) : string =
match response with
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index abda888..4845408 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -304,6 +304,12 @@ module VillageArt =
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
@@ -360,6 +366,76 @@ module VillageArt =
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) ]
+
+ /// Soft radial lantern glow drawn as concentric filled circles. Every ring carries a
+ /// low, premultiplied warm alpha so the rings sum to `peakAlpha` at the core and thin
+ /// out toward the rim; because each ring's rgb is premultiplied, 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 perRing = max 1 (peakAlpha / rings)
+ let glowColor = premultiply red green blue perRing
+ [ for ring in 0 .. rings - 1 do
+ let ringRadius = max 1 (radius * (rings - ring) / rings)
+ 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
@@ -481,9 +557,11 @@ module VillageArt =
(tint: XnaColor) =
let draw position sprite =
drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint
- let rippleFrame = waterFrameTick tick
- let waterSprite =
- match rippleFrame with
+
+ // 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
@@ -492,7 +570,7 @@ module VillageArt =
// onto the jiangnan atlas sprites with deterministic ripple continuity.
let drawTerrainTile (position: TilePosition) (code: int) =
match code with
- | 1 -> draw position waterSprite
+ | 1 -> draw position (waterSpriteAt position)
| 2 -> draw position StonePathSprite
| 3 -> draw position StonePathSprite
| _ -> draw position GrassSprite
@@ -522,7 +600,7 @@ module VillageArt =
for localPosition, kind in plan.Grounds do
let position = worldTile plan localPosition
match kind with
- | Water -> draw position waterSprite
+ | Water -> draw position (waterSpriteAt position)
| StonePath ->
match textures.Cc0TileAtlas with
| Some cc0 -> drawCc0Tile spriteBatch cc0 tint cc0PathRect (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y)
@@ -537,13 +615,6 @@ module VillageArt =
draw (offsetTile position -1 0) WallWindowSprite
draw (offsetTile position 0 0) DoorSprite
draw (offsetTile position 1 0) WallWoodSprite
- // Chimney smoke drifting up from the roof, cycling through three puffs.
- let smokeSprite =
- match smokeFrameTick tick with
- | 0 -> SmokeSpriteA
- | 1 -> SmokeSpriteB
- | _ -> SmokeSpriteC
- draw (offsetTile position -2 -3) smokeSprite
let drawBridge localPosition =
let position = worldTile plan localPosition
@@ -583,6 +654,19 @@ module VillageArt =
| StoneBench -> drawSingleTile prop.Position BenchSprite
| RiverLantern -> drawSingleTile prop.Position LanternSprite
+ // 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 plan.Props do
+ match prop.Kind with
+ | House -> draw (offsetTile (worldTile plan prop.Position) 0 -3) smokeSprite
+ | _ -> ()
+
let drawCharacter
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
@@ -590,12 +674,7 @@ module VillageArt =
(position: Vec2)
(spec: NpcSpriteSpec)
(tint: XnaColor) =
- let destination =
- XnaRectangle(
- int position.X - int camera.X - 16,
- int position.Y - int camera.Y - 48,
- 32,
- 48)
+ let destination = characterDestination position camera
spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint)
type InteriorLayout =