1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
|
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
// P64 内景下半部家具补件(椅/凳/床尾凳/壶)。
| Chair
| Stool
| BedFoot
| Teapot
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 =
// 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
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
// 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
// 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
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
/// 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
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<int> =
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
// 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)
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)
// 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
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)
// 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)
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)
|