castle-web-cli 0.4.128 → 0.4.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +4 -4
- package/dist/filesChanged.d.ts +2 -0
- package/dist/filesChanged.js +6 -2
- package/dist/ide.js +15 -4
- package/dist/importBrowse.js +1 -0
- package/dist/index.js +10 -4
- package/dist/init.js +63 -15
- package/dist/native/loop.js +1 -1
- package/dist/native/tools.js +1 -1
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +20 -1
- package/dist/shell/assets/index-BpOLUyVO.js +441 -0
- package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +40 -23
- package/kits/physics-2d/behaviors/Joints.jsx +4 -3
- package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
- package/kits/physics-2d/behaviors/Video.jsx +4 -3
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
- package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
- package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
- package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
- package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
- package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
- package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
- package/kits/physics-2d/engine/audioContext.js +41 -0
- package/kits/physics-2d/engine/autoInspector.jsx +50 -33
- package/kits/physics-2d/engine/blueprint.js +15 -3
- package/kits/physics-2d/engine/liveReload.js +33 -21
- package/kits/physics-2d/engine/media.js +29 -39
- package/kits/physics-2d/engine/sound.js +316 -0
- package/kits/physics-2d/engine/systemRegistry.js +1 -1
- package/kits/physics-2d/engine/ui.jsx +274 -5
- package/kits/physics-2d/engine/ui.module.css +137 -16
- package/kits/physics-2d/scripts/testsounds.mjs +84 -0
- package/kits/physics-2d/systems/media.js +31 -15
- package/kits/physics-3d/CLAUDE.md +109 -0
- package/kits/physics-3d/behaviors/Body.jsx +51 -0
- package/kits/physics-3d/behaviors/Door.jsx +31 -0
- package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
- package/kits/physics-3d/behaviors/Model.jsx +14 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
- package/kits/physics-3d/behaviors/Player.jsx +172 -0
- package/kits/physics-3d/behaviors/Portal.jsx +19 -0
- package/kits/physics-3d/behaviors/Shape.jsx +75 -0
- package/kits/physics-3d/behaviors/Solid.jsx +11 -0
- package/kits/physics-3d/behaviors/Transform.jsx +24 -0
- package/kits/physics-3d/blueprints/barrel.scene +15 -0
- package/kits/physics-3d/blueprints/crate.scene +29 -0
- package/kits/physics-3d/blueprints/door.scene +35 -0
- package/kits/physics-3d/blueprints/gem.scene +26 -0
- package/kits/physics-3d/blueprints/pillar.scene +25 -0
- package/kits/physics-3d/blueprints/platform.scene +23 -0
- package/kits/physics-3d/blueprints/player.scene +29 -0
- package/kits/physics-3d/blueprints/portal.scene +21 -0
- package/kits/physics-3d/blueprints/rock.scene +14 -0
- package/kits/physics-3d/blueprints/statue.scene +26 -0
- package/kits/physics-3d/blueprints/tree.scene +14 -0
- package/kits/physics-3d/blueprints/wall.scene +26 -0
- package/kits/{basic-2d → physics-3d}/castle.json +23 -5
- package/kits/physics-3d/docs/pxmodel-format.md +111 -0
- package/kits/physics-3d/drawings/crate.pxart +26 -0
- package/kits/physics-3d/drawings/door-edge.pxart +26 -0
- package/kits/physics-3d/drawings/door.pxart +27 -0
- package/kits/physics-3d/drawings/face.pxart +26 -0
- package/kits/physics-3d/drawings/floor.pxart +27 -0
- package/kits/physics-3d/drawings/platform.pxart +27 -0
- package/kits/physics-3d/drawings/statue.pxart +27 -0
- package/kits/physics-3d/drawings/wall.pxart +27 -0
- package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
- package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
- package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
- package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
- package/kits/physics-3d/engine3d/editor3dData.js +114 -0
- package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
- package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
- package/kits/physics-3d/engine3d/editorWorld.js +249 -0
- package/kits/physics-3d/engine3d/materials.js +174 -0
- package/kits/physics-3d/engine3d/meshops.js +123 -0
- package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
- package/kits/physics-3d/engine3d/modelFiles.js +139 -0
- package/kits/physics-3d/engine3d/pxmodel.js +265 -0
- package/kits/physics-3d/engine3d/thumbnails.js +137 -0
- package/kits/physics-3d/engine3d/world3d.js +216 -0
- package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
- package/kits/physics-3d/index.html +20 -0
- package/kits/physics-3d/main.jsx +30 -0
- package/kits/physics-3d/models/barrel.pxmodel +10 -0
- package/kits/physics-3d/models/player.pxmodel +222 -0
- package/kits/physics-3d/models/rock.pxmodel +30 -0
- package/kits/physics-3d/models/tree.pxmodel +10 -0
- package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
- package/kits/{basic-2d → physics-3d}/package.json +11 -9
- package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
- package/kits/physics-3d/scenes/main.scene +414 -0
- package/kits/physics-3d/scenes/model-lab.scene +127 -0
- package/kits/physics-3d/systems/physics3d.js +356 -0
- package/package.json +5 -2
- package/dist/shell/assets/index-DKu9ejyh.js +0 -436
- package/kits/basic-2d/CLAUDE.md +0 -221
- package/kits/basic-2d/behaviors/Camera.jsx +0 -43
- package/kits/basic-2d/behaviors/Collider.jsx +0 -213
- package/kits/basic-2d/behaviors/Layout.jsx +0 -53
- package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
- package/kits/basic-2d/behaviors/tint.js +0 -47
- package/kits/basic-2d/blueprints/cauldron.scene +0 -20
- package/kits/basic-2d/docs/pxart-format.md +0 -377
- package/kits/basic-2d/drawings/cauldron.pxart +0 -113
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
- package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
- package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
- package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
- package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
- package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
- package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
- package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
- package/kits/basic-2d/editors/editorHistory.js +0 -157
- package/kits/basic-2d/editors/inspectorSheet.js +0 -13
- package/kits/basic-2d/editors/pixelCanvas.js +0 -11
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
- package/kits/basic-2d/editors/pixelGeometry.js +0 -140
- package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
- package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
- package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
- package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
- package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
- package/kits/basic-2d/editors/pxArtTools.js +0 -232
- package/kits/basic-2d/editors/useArtboardFit.js +0 -105
- package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
- package/kits/basic-2d/engine/SceneUI.jsx +0 -59
- package/kits/basic-2d/engine/assets.js +0 -15
- package/kits/basic-2d/engine/autoInspector.jsx +0 -70
- package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
- package/kits/basic-2d/engine/blueprint.js +0 -557
- package/kits/basic-2d/engine/collider.js +0 -200
- package/kits/basic-2d/engine/files.js +0 -141
- package/kits/basic-2d/engine/liveReload.js +0 -88
- package/kits/basic-2d/engine/pxart.js +0 -1032
- package/kits/basic-2d/engine/pxartSmooth.js +0 -222
- package/kits/basic-2d/engine/scene.js +0 -696
- package/kits/basic-2d/engine/spriteGeometry.js +0 -32
- package/kits/basic-2d/engine/systemRegistry.js +0 -16
- package/kits/basic-2d/engine/ui.jsx +0 -695
- package/kits/basic-2d/engine/ui.module.css +0 -2287
- package/kits/basic-2d/index.html +0 -24
- package/kits/basic-2d/main.jsx +0 -24
- package/kits/basic-2d/scenes/main.scene +0 -16
- package/kits/basic-2d/scripts/draw.mjs +0 -121
- package/kits/physics-2d/behaviors/Sound.jsx +0 -163
- package/kits/physics-2d/behaviors/Tone.jsx +0 -95
- package/kits/physics-2d/engine/tone.js +0 -112
- /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
- /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
|
@@ -1072,7 +1072,8 @@
|
|
|
1072
1072
|
|
|
1073
1073
|
.fieldRowOverridden .input,
|
|
1074
1074
|
.fieldRowOverridden .select,
|
|
1075
|
-
.fieldRowOverridden .colorInput
|
|
1075
|
+
.fieldRowOverridden .colorInput,
|
|
1076
|
+
.fieldRowOverridden .checkbox {
|
|
1076
1077
|
border-color: var(--castle-override-border);
|
|
1077
1078
|
}
|
|
1078
1079
|
|
|
@@ -1084,6 +1085,25 @@
|
|
|
1084
1085
|
padding-bottom: 4px;
|
|
1085
1086
|
}
|
|
1086
1087
|
|
|
1088
|
+
/* A muted line of explanation under a field, sitting in the control column the
|
|
1089
|
+
same way the override sub-line above does. For a prop whose name can't carry
|
|
1090
|
+
its own meaning -- see `hint` in a behavior's propertyMeta. */
|
|
1091
|
+
.fieldNote {
|
|
1092
|
+
display: grid;
|
|
1093
|
+
grid-template-columns: minmax(82px, 0.5fr) minmax(0, 1fr);
|
|
1094
|
+
gap: 12px;
|
|
1095
|
+
margin-top: -6px;
|
|
1096
|
+
padding-bottom: 12px;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.fieldNoteInner {
|
|
1100
|
+
grid-column: 2;
|
|
1101
|
+
min-width: 0;
|
|
1102
|
+
font-size: 12px;
|
|
1103
|
+
line-height: 1.35;
|
|
1104
|
+
color: var(--castle-inspector-muted);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1087
1107
|
.fieldDefault {
|
|
1088
1108
|
display: grid;
|
|
1089
1109
|
grid-template-columns: minmax(82px, 0.5fr) minmax(0, 1fr);
|
|
@@ -1143,6 +1163,64 @@
|
|
|
1143
1163
|
text-overflow: ellipsis;
|
|
1144
1164
|
}
|
|
1145
1165
|
|
|
1166
|
+
.fileField {
|
|
1167
|
+
display: flex;
|
|
1168
|
+
align-items: center;
|
|
1169
|
+
font: inherit;
|
|
1170
|
+
box-shadow: none;
|
|
1171
|
+
cursor: pointer;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
.fileFieldLabel {
|
|
1175
|
+
min-width: 0;
|
|
1176
|
+
overflow: hidden;
|
|
1177
|
+
text-overflow: ellipsis;
|
|
1178
|
+
white-space: nowrap;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
.fileFieldEmpty {
|
|
1182
|
+
color: var(--castle-inspector-muted);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
.filePicker {
|
|
1186
|
+
position: fixed;
|
|
1187
|
+
z-index: 200;
|
|
1188
|
+
box-sizing: border-box;
|
|
1189
|
+
max-height: min(240px, calc(100vh - 16px));
|
|
1190
|
+
overflow: auto;
|
|
1191
|
+
padding: 4px;
|
|
1192
|
+
border: 1px solid var(--castle-inspector-border);
|
|
1193
|
+
border-radius: var(--castle-radius);
|
|
1194
|
+
background: var(--castle-workspace-bg);
|
|
1195
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.filePickerOption {
|
|
1199
|
+
display: block;
|
|
1200
|
+
width: 100%;
|
|
1201
|
+
overflow: hidden;
|
|
1202
|
+
text-align: left;
|
|
1203
|
+
border: 0;
|
|
1204
|
+
border-radius: 3px;
|
|
1205
|
+
background: transparent;
|
|
1206
|
+
color: var(--castle-inspector-text);
|
|
1207
|
+
font: inherit;
|
|
1208
|
+
font-size: 13px;
|
|
1209
|
+
padding: 6px 8px;
|
|
1210
|
+
cursor: pointer;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.filePickerPath {
|
|
1214
|
+
display: block;
|
|
1215
|
+
overflow: hidden;
|
|
1216
|
+
white-space: nowrap;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
.filePickerOption:hover,
|
|
1220
|
+
.filePickerOptionSelected {
|
|
1221
|
+
background: var(--castle-inspector-input-bg);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1146
1224
|
.numberField {
|
|
1147
1225
|
position: relative;
|
|
1148
1226
|
width: 100%;
|
|
@@ -1383,34 +1461,35 @@
|
|
|
1383
1461
|
cursor: pointer;
|
|
1384
1462
|
}
|
|
1385
1463
|
|
|
1386
|
-
.
|
|
1387
|
-
width:
|
|
1388
|
-
height:
|
|
1464
|
+
.checkbox {
|
|
1465
|
+
width: 20px;
|
|
1466
|
+
height: 20px;
|
|
1467
|
+
flex: 0 0 auto;
|
|
1468
|
+
justify-self: end;
|
|
1389
1469
|
border: 1px solid var(--castle-inspector-border);
|
|
1390
|
-
border-radius:
|
|
1470
|
+
border-radius: var(--castle-radius);
|
|
1391
1471
|
background: var(--castle-inspector-input-bg);
|
|
1392
|
-
padding:
|
|
1472
|
+
padding: 0;
|
|
1393
1473
|
cursor: pointer;
|
|
1394
1474
|
display: flex;
|
|
1395
1475
|
align-items: center;
|
|
1396
|
-
justify-content:
|
|
1476
|
+
justify-content: center;
|
|
1397
1477
|
box-shadow: none;
|
|
1478
|
+
color: var(--castle-text);
|
|
1398
1479
|
}
|
|
1399
1480
|
|
|
1400
|
-
.
|
|
1481
|
+
.checkboxOn {
|
|
1401
1482
|
background: var(--castle-black);
|
|
1402
|
-
justify-content: flex-end;
|
|
1403
1483
|
}
|
|
1404
1484
|
|
|
1405
|
-
.
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
background: var(--castle-inspector-muted);
|
|
1485
|
+
.checkboxMark {
|
|
1486
|
+
display: none;
|
|
1487
|
+
width: 12px;
|
|
1488
|
+
height: 12px;
|
|
1410
1489
|
}
|
|
1411
1490
|
|
|
1412
|
-
.
|
|
1413
|
-
|
|
1491
|
+
.checkboxOn .checkboxMark {
|
|
1492
|
+
display: block;
|
|
1414
1493
|
}
|
|
1415
1494
|
|
|
1416
1495
|
.textarea {
|
|
@@ -1471,6 +1550,11 @@
|
|
|
1471
1550
|
cursor: default;
|
|
1472
1551
|
}
|
|
1473
1552
|
|
|
1553
|
+
.button:disabled:hover,
|
|
1554
|
+
.iconButton:disabled:hover {
|
|
1555
|
+
background: var(--castle-inspector-button-bg);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1474
1558
|
.actorList {
|
|
1475
1559
|
display: grid;
|
|
1476
1560
|
gap: 4px;
|
|
@@ -1529,6 +1613,13 @@
|
|
|
1529
1613
|
display: none;
|
|
1530
1614
|
}
|
|
1531
1615
|
|
|
1616
|
+
/* Read-only paint/inspector chrome: still there so the layout doesn't jump,
|
|
1617
|
+
but clearly not interactive. */
|
|
1618
|
+
.drawingReadOnly {
|
|
1619
|
+
opacity: 0.45;
|
|
1620
|
+
pointer-events: none;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1532
1623
|
/* Stack the tool rail and the undo/redo rail vertically (the shared rule above is
|
|
1533
1624
|
a flex row, which pushed the second rail out of the 50px column). */
|
|
1534
1625
|
.artboardToolColumn {
|
|
@@ -1642,6 +1733,22 @@
|
|
|
1642
1733
|
color: var(--castle-selected-ink);
|
|
1643
1734
|
}
|
|
1644
1735
|
|
|
1736
|
+
.finishSegment:disabled {
|
|
1737
|
+
cursor: default;
|
|
1738
|
+
opacity: 0.4;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
.finishSegment:disabled:hover {
|
|
1742
|
+
background: var(--castle-inspector-button-bg);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
.finishSegment.finishSegmentOn:disabled,
|
|
1746
|
+
.finishSegment.finishSegmentOn:disabled:hover {
|
|
1747
|
+
opacity: 0.85;
|
|
1748
|
+
background: var(--castle-selected);
|
|
1749
|
+
color: var(--castle-selected-ink);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1645
1752
|
.canvasSizeSep {
|
|
1646
1753
|
color: var(--castle-inspector-muted);
|
|
1647
1754
|
user-select: none;
|
|
@@ -1663,6 +1770,12 @@
|
|
|
1663
1770
|
background-position: right 6px center;
|
|
1664
1771
|
}
|
|
1665
1772
|
|
|
1773
|
+
.canvasSizeSelect:disabled {
|
|
1774
|
+
opacity: 0.55;
|
|
1775
|
+
cursor: default;
|
|
1776
|
+
color: var(--castle-inspector-muted);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1666
1779
|
.pxArtInspector {
|
|
1667
1780
|
min-width: 0;
|
|
1668
1781
|
border-left: 1px solid var(--castle-inspector-divider);
|
|
@@ -2042,6 +2155,14 @@
|
|
|
2042
2155
|
cursor: default;
|
|
2043
2156
|
}
|
|
2044
2157
|
|
|
2158
|
+
.drawingToolButton.drawingToolSelected:disabled {
|
|
2159
|
+
opacity: 0.7;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
.drawingToolButton:disabled:hover {
|
|
2163
|
+
background: var(--castle-inspector-button-bg);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2045
2166
|
/* Active / selected square icon button -- the eyedropper reference: a light
|
|
2046
2167
|
fill with a dark glyph. The single selected/engaged look shared by the
|
|
2047
2168
|
IconButton component (`active`), the onion-skin toggle, and the pixel
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generate audio to develop the sound library against, so the kit can be
|
|
3
|
+
// exercised without committing binaries to it.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/testsounds.mjs # into this kit
|
|
6
|
+
// node scripts/testsounds.mjs ../../some-deck
|
|
7
|
+
// node scripts/testsounds.mjs . --dup # also a clashing stem, to see the warning
|
|
8
|
+
// node scripts/testsounds.mjs . --long # also a 20s file INSIDE assets/sounds/
|
|
9
|
+
//
|
|
10
|
+
// Writes 16-bit mono PCM wavs. The cases are chosen to be awkward on purpose: a
|
|
11
|
+
// 10ms click is shorter than a frame, and the drone is long enough that decoding
|
|
12
|
+
// it into memory is the wrong thing -- which is what `assets/music/` is for.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const RATE = 22050;
|
|
18
|
+
|
|
19
|
+
function wav(samples) {
|
|
20
|
+
const data = Buffer.alloc(samples.length * 2);
|
|
21
|
+
for (let i = 0; i < samples.length; i++) {
|
|
22
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
23
|
+
data.writeInt16LE(Math.round(clamped * 32767), i * 2);
|
|
24
|
+
}
|
|
25
|
+
const header = Buffer.alloc(44);
|
|
26
|
+
header.write('RIFF', 0);
|
|
27
|
+
header.writeUInt32LE(36 + data.length, 4);
|
|
28
|
+
header.write('WAVE', 8);
|
|
29
|
+
header.write('fmt ', 12);
|
|
30
|
+
header.writeUInt32LE(16, 16);
|
|
31
|
+
header.writeUInt16LE(1, 20); // PCM
|
|
32
|
+
header.writeUInt16LE(1, 22); // mono
|
|
33
|
+
header.writeUInt32LE(RATE, 24);
|
|
34
|
+
header.writeUInt32LE(RATE * 2, 28);
|
|
35
|
+
header.writeUInt16LE(2, 32);
|
|
36
|
+
header.writeUInt16LE(16, 34);
|
|
37
|
+
header.write('data', 36);
|
|
38
|
+
header.writeUInt32LE(data.length, 40);
|
|
39
|
+
return Buffer.concat([header, data]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `shape(t, i)` gets seconds elapsed and the sample index, and returns -1..1.
|
|
43
|
+
function render(seconds, shape) {
|
|
44
|
+
const total = Math.max(1, Math.round(seconds * RATE));
|
|
45
|
+
const samples = new Float32Array(total);
|
|
46
|
+
for (let i = 0; i < total; i++) samples[i] = shape(i / RATE, i);
|
|
47
|
+
return samples;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const decay = (t, seconds) => Math.max(0, 1 - t / seconds);
|
|
51
|
+
const tone = (t, hz) => Math.sin(2 * Math.PI * hz * t);
|
|
52
|
+
const noise = () => Math.random() * 2 - 1;
|
|
53
|
+
|
|
54
|
+
const SOUNDS = {
|
|
55
|
+
// A rising blip: the ordinary pickup / UI confirmation.
|
|
56
|
+
'assets/sounds/blip.wav': render(0.12, (t) => tone(t, 660 + t * 1800) * decay(t, 0.12) * 0.7),
|
|
57
|
+
// A low thump with a noise transient, for an impact.
|
|
58
|
+
'assets/sounds/thud.wav': render(0.2, (t) => (tone(t, 90) * 0.8 + noise() * 0.25) * decay(t, 0.2) ** 2),
|
|
59
|
+
// 10ms -- shorter than a frame at 60fps, and shorter than most scheduling
|
|
60
|
+
// slop. If anything in the pipeline rounds or defers, this is what shows it.
|
|
61
|
+
'assets/sounds/click.wav': render(0.01, (t) => noise() * decay(t, 0.01)),
|
|
62
|
+
// Long, and deliberately OUTSIDE assets/sounds/: 20s decoded is ~3.5MB here and
|
|
63
|
+
// would be ~70MB as 44.1kHz stereo float. This is the streaming case.
|
|
64
|
+
'assets/music/drone.wav': render(20, (t) => (tone(t, 110) * 0.5 + tone(t, 165) * 0.3) * 0.5),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const args = process.argv.slice(2);
|
|
68
|
+
const target = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
|
|
69
|
+
const extras = {};
|
|
70
|
+
if (args.includes('--dup')) {
|
|
71
|
+
// Same stem as assets/sounds/click.wav, so `play('click')` becomes ambiguous.
|
|
72
|
+
extras['assets/sounds/ui/click.wav'] = render(0.03, (t) => tone(t, 1200) * decay(t, 0.03) * 0.5);
|
|
73
|
+
}
|
|
74
|
+
if (args.includes('--long')) {
|
|
75
|
+
extras['assets/sounds/toolong.wav'] = render(20, (t) => tone(t, 220) * 0.4 * decay(t, 20));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [relative, samples] of Object.entries({ ...SOUNDS, ...extras })) {
|
|
79
|
+
const path = join(target, relative);
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
const bytes = wav(samples);
|
|
82
|
+
writeFileSync(path, bytes);
|
|
83
|
+
console.log(`${relative} ${(bytes.length / 1024).toFixed(1)}KB ${(samples.length / RATE).toFixed(2)}s`);
|
|
84
|
+
}
|
|
@@ -1,29 +1,45 @@
|
|
|
1
|
-
// Registers
|
|
1
|
+
// Registers audio / video playback as a runtime system. Auto-discovered by
|
|
2
2
|
// engine/systemRegistry.js (`systems/*.js`) and invoked by makeScene, so the
|
|
3
|
-
//
|
|
3
|
+
// playback machinery plugs in without the shared engine referencing it.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Two things play sound in this kit and this is the one place that knows about
|
|
6
|
+
// both: `engine/media.js` streams an `<audio>` / `<video>` element (long tracks,
|
|
7
|
+
// video), and `engine/sound.js` fires decoded buffers (sound effects). They share
|
|
8
|
+
// one AudioContext and are silenced together.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// The system owns the LIFECYCLE only -- behaviors and game code decide what
|
|
11
|
+
// plays. It reaps idle elements once a frame (a despawned actor's sound stops),
|
|
12
|
+
// and silences everything on `reset` (a scene load / restart / transition) and on
|
|
13
|
+
// `dispose` (the player unmounting, i.e. pressing Stop).
|
|
14
|
+
//
|
|
15
|
+
// It also puts `scene.sound` on the runtime: the sound library itself, so a
|
|
16
|
+
// behavior can `scene.sound.play('hit')` with no import, plus the scene-wide
|
|
17
|
+
// controls, which belong to nobody's actor.
|
|
12
18
|
|
|
19
|
+
import { initialMedia, mediaFilesOfKind } from '../engine/files';
|
|
13
20
|
import { reapUnusedMedia, stopAllMedia } from '../engine/media';
|
|
14
|
-
import {
|
|
21
|
+
import { Sound, initSounds, stopAllSounds } from '../engine/sound';
|
|
22
|
+
|
|
23
|
+
// Preloading is the DECK's, not a scene's, so it starts here at module load
|
|
24
|
+
// rather than per-runtime -- the editor builds a throwaway runtime every frame
|
|
25
|
+
// to draw its preview, and decoding the deck's sounds sixty times a second is
|
|
26
|
+
// not a thing to do. Vite evaluates a module once, which is exactly the scope
|
|
27
|
+
// wanted.
|
|
28
|
+
initSounds(
|
|
29
|
+
Object.fromEntries(mediaFilesOfKind('audio').map((path) => [path, initialMedia[path]]))
|
|
30
|
+
);
|
|
15
31
|
|
|
16
32
|
function silence() {
|
|
17
33
|
stopAllMedia();
|
|
18
|
-
|
|
34
|
+
stopAllSounds();
|
|
19
35
|
}
|
|
20
36
|
|
|
21
37
|
export function installSystem(runtime) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
38
|
+
// The library itself, with `stopAll` widened to mean everything the scene is
|
|
39
|
+
// playing rather than just its sound effects. Delegating through the prototype
|
|
40
|
+
// rather than copying keeps `isLoaded` a live getter (a spread would snapshot
|
|
41
|
+
// it as false forever) and keeps the two in step as the library grows.
|
|
42
|
+
runtime.sound = Object.create(Sound, { stopAll: { value: silence, enumerable: true } });
|
|
27
43
|
runtime.registerSystem({
|
|
28
44
|
afterBehaviors(scene) {
|
|
29
45
|
reapUnusedMedia(new Set(scene.actors.keys()));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# physics-3d kit
|
|
2
|
+
|
|
3
|
+
A 3D actor/behavior kit: three.js rendering with PBR lighting and rapier
|
|
4
|
+
physics, built ON TOP of the `physics-2d` kit as an import. The actor /
|
|
5
|
+
behavior / scene / blueprint model, deck file map, live reload, and the
|
|
6
|
+
`.pxart` pixel-art pipeline (including its editor) all come from
|
|
7
|
+
`imports/castle.physics-2d/` and work exactly as that kit's CLAUDE.md
|
|
8
|
+
describes. This file covers what is different in 3D.
|
|
9
|
+
|
|
10
|
+
## Quick reference
|
|
11
|
+
|
|
12
|
+
<!-- Injected into Castle's create assistant every turn; keep this compact and factual. -->
|
|
13
|
+
|
|
14
|
+
- 3D actor / behavior / scene framework: three.js + rapier, 1 unit = 1 meter,
|
|
15
|
+
y up. Scenes are `scenes/*.scene` (blueprints too, under `blueprints/`),
|
|
16
|
+
behaviors `behaviors/*.jsx` -- same JSON shapes as physics-2d, but the
|
|
17
|
+
component vocabulary is 3D (`Transform`, `Shape`, `Body`, `Solid`, ...).
|
|
18
|
+
- Every actor needs a `Transform` (`x/y/z`, `rotationX/Y/Z` in degrees).
|
|
19
|
+
Every Transform actor gets a rapier body automatically -- fixed by default,
|
|
20
|
+
`Body { type: 'dynamic' }` to be simulated, `'kinematic'` to be unpushable
|
|
21
|
+
but follow Transform edits (doors, platforms).
|
|
22
|
+
- Colliders come from `Shape` and are **sensors unless solid**: add a
|
|
23
|
+
`Solid {}` behavior (or `Shape.solid: true`) to block; leave it off for
|
|
24
|
+
pickups / trigger zones. Behaviors get `onCollisionEnter(actor, scene,
|
|
25
|
+
other)` / `onCollisionExit` for both contacts and sensor overlaps.
|
|
26
|
+
- `Shape`: `kind` `'box' | 'sphere' | 'capsule' | 'cylinder'` with
|
|
27
|
+
`width/height/depth` or `radius`, PBR knobs (`color`, `roughness`,
|
|
28
|
+
`metalness`, `clearcoat`, `emissive`, `emissiveIntensity`), and `art` -- a
|
|
29
|
+
`.pxart`/image path baked as a nearest-filtered albedo map. On boxes `art`
|
|
30
|
+
may be a per-face object `{ all, sides, front, back, left, right, top,
|
|
31
|
+
bottom }`; any ref can pick a frame of a multi-frame pxart with
|
|
32
|
+
`path#<frame>`. A flat `color` with no art is a fine look.
|
|
33
|
+
- Motion goes through `scene.physics3d`, never by writing a dynamic actor's
|
|
34
|
+
Transform: `setVelocity(actor, {x,y,z})`, `applyImpulse`, `getVelocity`,
|
|
35
|
+
`mass`, `castRay(origin, dir, maxToi, excludeActor)` (rays skip sensors).
|
|
36
|
+
Fixed/kinematic actors ARE moved by writing their Transform.
|
|
37
|
+
- The scene has the lighting rig built in (hemisphere + ambient + one
|
|
38
|
+
shadow-casting sun, ground plane at y=0); meshes cast and receive shadows
|
|
39
|
+
automatically. Don't add your own lights for a normal scene.
|
|
40
|
+
- Built-in gameplay behaviors: `Player` (WASD/arrows move, space jumps,
|
|
41
|
+
drag orbits the camera, faux-isometric follow cam), `Pickup` (sensor
|
|
42
|
+
collectible -> player score), `Door` (kinematic slide-up when the player is
|
|
43
|
+
near), `Lookable { text }` (hud caption when the player faces it nearby).
|
|
44
|
+
- Hud: behaviors write `scene.hud = { score, look }`; the player behavior
|
|
45
|
+
already does.
|
|
46
|
+
- Scene switching: place a `Portal { scene: 'scenes/other.scene' }` actor (a
|
|
47
|
+
sensor -- the portal blueprint is a glowing ring), or set
|
|
48
|
+
`scene.requestedScene = 'scenes/other.scene'` from any behavior (applied
|
|
49
|
+
between frames; safer than calling `scene.loadFromFile` mid-callback). A
|
|
50
|
+
load rebuilds the physics world and resets the scene to its authored state;
|
|
51
|
+
`scene.persistent` survives loads -- the player's score, collected-pickup
|
|
52
|
+
memory, and camera angle live there, so they cross scenes.
|
|
53
|
+
- A scene can dress the ground: `"ground": { "art": "drawings/floor.pxart",
|
|
54
|
+
"tile": 2, "color": "#..." }` at the scene file's top level (`tile` = meters
|
|
55
|
+
per texture repeat). `Shape.artRepeat: [u, v]` tiles a texture across a
|
|
56
|
+
face so long walls don't stretch.
|
|
57
|
+
- `.scene` files open in the kit's 3d scene editor (same chrome as the 2d
|
|
58
|
+
kit's): blueprint hotbar (tap to inspect/edit the template, drag onto the
|
|
59
|
+
stage to place), orbit camera (drag orbits, wheel zooms), click to select /
|
|
60
|
+
click again to cycle / shift to multi-select, move & rotate gizmos (keys
|
|
61
|
+
1/2/3 pick select/move/rotate), floating clone/delete toolbar on the
|
|
62
|
+
selection, behavior-panel inspector with instance-override tints, undo/redo
|
|
63
|
+
(cmd+z / cmd+shift+z), cmd+d duplicate, delete key removes. `.pxart` opens
|
|
64
|
+
in the imported pixel-art editor.
|
|
65
|
+
- Blueprint thumbnails in the hotbar / inspector are live 3d corner-view
|
|
66
|
+
renders of each template (engine3d/thumbnails.js) -- no icon data needed on
|
|
67
|
+
the blueprint.
|
|
68
|
+
- 3d models are `.pxmodel` files under `models/` -- part-based JSON (named
|
|
69
|
+
primitive parts with hierarchy, per-part material/art), spec in
|
|
70
|
+
docs/pxmodel-format.md. A `Model` behavior (`{ file, scale, yOffset }`)
|
|
71
|
+
renders one instead of the Shape mesh; part ids legL/legR/armL/armR/torso
|
|
72
|
+
walk-animate automatically from observed motion. `.pxmodel` opens in the
|
|
73
|
+
kit's model editor (parts list, gizmos, part inspector, walk preview);
|
|
74
|
+
`scenes/model-lab.scene` is an all-model scene for trying it.
|
|
75
|
+
- Controls are tank-style: W/S (or up/down) walk forward/back along facing,
|
|
76
|
+
A/D (or left/right) turn; a pointer/touch drag is a virtual stick where the
|
|
77
|
+
drag angle blends walking and turning. Space jumps.
|
|
78
|
+
- After any edit: `npm run restart`. After a coherent change: `npm run check`
|
|
79
|
+
(lint + duplicate gate + single-file bundle) must pass.
|
|
80
|
+
|
|
81
|
+
## What lives where
|
|
82
|
+
|
|
83
|
+
- `engine3d/` -- the renderer (`world3d.js`, vase-style rig: PCFSoft shadows,
|
|
84
|
+
EffectComposer RenderPass + OutputPass), `materials.js` (pxart -> albedo
|
|
85
|
+
textures, per-face box materials), `Scene3DPlayer.jsx` / `PlayOnly3D.jsx`
|
|
86
|
+
(the play mount). Internal; behaviors shouldn't import from here.
|
|
87
|
+
- `systems/physics3d.js` -- the rapier system, auto-discovered by the imported
|
|
88
|
+
engine's `systems/*.js` seam. Fixed 1/60 step, body-per-actor reconcile,
|
|
89
|
+
collision/overlap events, `scene.physics3d` API.
|
|
90
|
+
- `behaviors/` -- the 3D component vocabulary plus gameplay behaviors. Same
|
|
91
|
+
rules as physics-2d: `static behaviorName`, `defaultProps`, per-frame
|
|
92
|
+
instances (state on `actor.runtime`).
|
|
93
|
+
- `imports/castle.physics-2d/` -- read-only. Its engine modules are imported
|
|
94
|
+
directly (scene runtime, files, live reload, pxart, art, behavior
|
|
95
|
+
registry); its 2D-only pieces (canvas ScenePlayer, matter physics, 2D
|
|
96
|
+
editors other than pxart) are present but unused here.
|
|
97
|
+
|
|
98
|
+
## Known state / caveats
|
|
99
|
+
|
|
100
|
+
- The 2D kit's behaviors (Layout, Sprite, Collider, RigidBody, ...) are still
|
|
101
|
+
in the behavior registry via the import's root-anchored globs, and its
|
|
102
|
+
matter-js system installs (idle) into every runtime. Harmless but present;
|
|
103
|
+
don't attach 2D components to 3D actors.
|
|
104
|
+
- Transform is the source of truth: dynamic bodies write back position (and
|
|
105
|
+
rotation unless `lockRotations`), fixed/kinematic bodies follow Transform.
|
|
106
|
+
A locked-rotation dynamic actor (the player) keeps `rotationY` free for
|
|
107
|
+
facing.
|
|
108
|
+
- Space is bound to jump in this kit (unlike physics-2d, where the editor
|
|
109
|
+
reserves it).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Physics options. Every actor with a Transform gets a rapier body whether or
|
|
2
|
+
// not it carries this behavior -- fixed by default. Add a Body to opt into
|
|
3
|
+
// motion: 'dynamic' is simulated (gravity, pushes), 'kinematic' is unpushable
|
|
4
|
+
// but follows Transform edits with proper contact resolution (moving doors,
|
|
5
|
+
// platforms). `lockRotations` keeps a dynamic body upright (characters).
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { SelectField } from '@imports/castle.physics-2d/engine/ui';
|
|
8
|
+
import { AutoFields, overrideProps } from '@imports/castle.physics-2d/engine/autoInspector';
|
|
9
|
+
|
|
10
|
+
const TYPE_OPTIONS = [
|
|
11
|
+
{ value: 'fixed', label: 'Fixed' },
|
|
12
|
+
{ value: 'dynamic', label: 'Dynamic' },
|
|
13
|
+
{ value: 'kinematic', label: 'Kinematic' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function BodyInspector({ component, setComponent, override }) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<SelectField
|
|
20
|
+
label="Type"
|
|
21
|
+
value={component.type}
|
|
22
|
+
options={TYPE_OPTIONS}
|
|
23
|
+
onChange={(type) => setComponent({ type })}
|
|
24
|
+
{...overrideProps(override, 'type')}
|
|
25
|
+
/>
|
|
26
|
+
<AutoFields
|
|
27
|
+
defaultProps={Body.defaultProps}
|
|
28
|
+
component={component}
|
|
29
|
+
setComponent={setComponent}
|
|
30
|
+
exclude={['type']}
|
|
31
|
+
override={override}
|
|
32
|
+
/>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class Body {
|
|
38
|
+
static Inspector = BodyInspector;
|
|
39
|
+
static behaviorName = 'Body';
|
|
40
|
+
static defaultProps = {
|
|
41
|
+
type: 'fixed', // 'fixed' | 'dynamic' | 'kinematic'
|
|
42
|
+
friction: 0.6,
|
|
43
|
+
restitution: 0,
|
|
44
|
+
gravityScale: 1,
|
|
45
|
+
lockRotations: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
constructor(props) {
|
|
49
|
+
this.props = props;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A sliding door: solid, kinematic, and opens (slides up) while the player is
|
|
2
|
+
// within range. Motion is written to the Transform; the physics system feeds
|
|
3
|
+
// it to the kinematic body so contacts resolve properly.
|
|
4
|
+
export class Door {
|
|
5
|
+
static behaviorName = 'Door';
|
|
6
|
+
static defaultProps = { range: 3, slide: 2.1, speed: 2.5 };
|
|
7
|
+
|
|
8
|
+
constructor(props) {
|
|
9
|
+
this.props = props;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
update(actor, scene, dt) {
|
|
13
|
+
const player = scene.actorWith('Player');
|
|
14
|
+
const pt = player?.components.Transform;
|
|
15
|
+
const t = actor.components.Transform;
|
|
16
|
+
const rt = actor.runtime;
|
|
17
|
+
if (rt.openT === undefined) {
|
|
18
|
+
rt.openT = 0;
|
|
19
|
+
rt.baseY = t.y;
|
|
20
|
+
}
|
|
21
|
+
let target = 0;
|
|
22
|
+
if (pt) {
|
|
23
|
+
const dist = Math.hypot(pt.x - t.x, pt.z - t.z);
|
|
24
|
+
target = dist < this.props.range ? 1 : 0;
|
|
25
|
+
}
|
|
26
|
+
const delta = Math.sign(target - rt.openT) * this.props.speed * dt;
|
|
27
|
+
rt.openT = Math.max(0, Math.min(1, rt.openT + delta));
|
|
28
|
+
const eased = rt.openT * rt.openT * (3 - 2 * rt.openT);
|
|
29
|
+
t.y = rt.baseY + eased * this.props.slide;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Something the player can look at. When the player is near and facing this
|
|
2
|
+
// actor, the hud shows `text`. Pure data -- the Player behavior does the
|
|
3
|
+
// looking.
|
|
4
|
+
export class Lookable {
|
|
5
|
+
static behaviorName = 'Lookable';
|
|
6
|
+
static defaultProps = { text: '', range: 4 };
|
|
7
|
+
|
|
8
|
+
constructor(props) {
|
|
9
|
+
this.props = props;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Render a `.pxmodel` (see docs/pxmodel-format.md) instead of the Shape mesh.
|
|
2
|
+
// The Shape still provides the physics collider. `yOffset` places the model's
|
|
3
|
+
// own origin relative to the actor's Transform -- e.g. -0.7 puts a
|
|
4
|
+
// feet-at-zero model under a capsule whose center is the Transform. Models
|
|
5
|
+
// with the walk part ids (legL/legR/armL/armR/torso) animate automatically
|
|
6
|
+
// from how the actor actually moves.
|
|
7
|
+
export class Model {
|
|
8
|
+
static behaviorName = 'Model';
|
|
9
|
+
static defaultProps = { file: '', scale: 1, yOffset: 0 };
|
|
10
|
+
|
|
11
|
+
constructor(props) {
|
|
12
|
+
this.props = props;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A collectible: bobs and spins in place, and despawns into the player's score
|
|
2
|
+
// on touch. No Solid behavior, so its collider is a sensor -- it overlaps
|
|
3
|
+
// instead of blocking, which is what fires onCollisionEnter here.
|
|
4
|
+
//
|
|
5
|
+
// Collection is remembered in the persistent store keyed by scene + actor id,
|
|
6
|
+
// so walking back through a portal doesn't respawn (and double-count) shards
|
|
7
|
+
// -- scene loads otherwise reset the world to its authored state.
|
|
8
|
+
function pickupKey(scene, actor) {
|
|
9
|
+
return `${scene.currentScenePath ?? ''}:${actor.id}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Pickup {
|
|
13
|
+
static behaviorName = 'Pickup';
|
|
14
|
+
static defaultProps = { bob: 0.12, spin: 120 };
|
|
15
|
+
|
|
16
|
+
constructor(props) {
|
|
17
|
+
this.props = props;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
update(actor, scene, dt) {
|
|
21
|
+
if (scene.persistent?.collected?.has(pickupKey(scene, actor))) {
|
|
22
|
+
scene.despawnActor(actor.id);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const t = actor.components.Transform;
|
|
26
|
+
const rt = actor.runtime;
|
|
27
|
+
if (rt.baseY === undefined) rt.baseY = t.y;
|
|
28
|
+
t.y = rt.baseY + Math.sin(scene.time * 2.5) * this.props.bob;
|
|
29
|
+
t.rotationY += this.props.spin * dt;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onCollisionEnter(actor, scene, other) {
|
|
33
|
+
if (!other.components.Player) return;
|
|
34
|
+
const per = (scene.persistent ??= {});
|
|
35
|
+
per.score = (per.score ?? 0) + 1;
|
|
36
|
+
(per.collected ??= new Set()).add(pickupKey(scene, actor));
|
|
37
|
+
scene.despawnActor(actor.id);
|
|
38
|
+
}
|
|
39
|
+
}
|