castle-web-cli 0.4.128 → 0.4.129
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/kits/physics-2d/CLAUDE.md +31 -13
- 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/engine/audioContext.js +41 -0
- package/kits/physics-2d/engine/autoInspector.jsx +50 -33
- package/kits/physics-2d/engine/media.js +29 -39
- package/kits/physics-2d/engine/sound.js +316 -0
- package/kits/physics-2d/engine/ui.jsx +272 -3
- package/kits/physics-2d/engine/ui.module.css +95 -16
- package/kits/physics-2d/scripts/testsounds.mjs +84 -0
- package/kits/physics-2d/systems/media.js +31 -15
- package/package.json +1 -1
- 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
|
@@ -627,15 +627,35 @@ export function CheckboxField({ label, checked, onChange, overridden, defaultVal
|
|
|
627
627
|
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
628
628
|
<button
|
|
629
629
|
type="button"
|
|
630
|
-
className={cx(styles.
|
|
631
|
-
role="
|
|
630
|
+
className={cx(styles.checkbox, checked && styles.checkboxOn)}
|
|
631
|
+
role="checkbox"
|
|
632
632
|
aria-checked={!!checked}
|
|
633
633
|
onClick={() => onChange(!checked)}>
|
|
634
|
-
<
|
|
634
|
+
<svg className={styles.checkboxMark} viewBox="0 0 12 12" aria-hidden="true">
|
|
635
|
+
<path
|
|
636
|
+
d="M2 6.2L4.8 9 10 3"
|
|
637
|
+
fill="none"
|
|
638
|
+
stroke="currentColor"
|
|
639
|
+
strokeWidth="1.8"
|
|
640
|
+
strokeLinecap="round"
|
|
641
|
+
strokeLinejoin="round"
|
|
642
|
+
/>
|
|
643
|
+
</svg>
|
|
635
644
|
</button>
|
|
636
645
|
</FieldRow>
|
|
637
646
|
);
|
|
638
647
|
}
|
|
648
|
+
// Explanatory text under a field, for a prop whose name doesn't carry its
|
|
649
|
+
// meaning. Rendered inside the panel's field grid, so it lands under the control
|
|
650
|
+
// rather than under the label.
|
|
651
|
+
export function FieldNote({ children }) {
|
|
652
|
+
if (!children) return null;
|
|
653
|
+
return (
|
|
654
|
+
<div className={styles.fieldNote}>
|
|
655
|
+
<div className={styles.fieldNoteInner}>{children}</div>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
639
659
|
export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
|
|
640
660
|
const hex = normalizeHex(value);
|
|
641
661
|
const alpha = hex.length === 9 ? hex.slice(7) : '';
|
|
@@ -947,3 +967,252 @@ export function SelectField({ label, value, onChange, options, overridden, defau
|
|
|
947
967
|
</FieldRow>
|
|
948
968
|
);
|
|
949
969
|
}
|
|
970
|
+
|
|
971
|
+
function basenameOf(path) {
|
|
972
|
+
if (!path) return '';
|
|
973
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
let measureCanvasCtx;
|
|
977
|
+
function measureTextWidth(text, font) {
|
|
978
|
+
if (!measureCanvasCtx) {
|
|
979
|
+
measureCanvasCtx = document.createElement('canvas').getContext('2d');
|
|
980
|
+
}
|
|
981
|
+
measureCanvasCtx.font = font;
|
|
982
|
+
return measureCanvasCtx.measureText(text).width;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Keep the filename intact; the directory is what yields. Binary-search the
|
|
986
|
+
// prefix of the directory until `{prefix}…/{basename}` fits `maxWidth`.
|
|
987
|
+
function fitPathKeepBasename(path, maxWidth, font) {
|
|
988
|
+
const measure = (text) => measureTextWidth(text, font);
|
|
989
|
+
if (maxWidth <= 0 || measure(path) <= maxWidth) return path;
|
|
990
|
+
const slash = path.lastIndexOf('/');
|
|
991
|
+
const dir = slash > 0 ? path.slice(0, slash) : '';
|
|
992
|
+
const base = slash > 0 ? path.slice(slash + 1) : path;
|
|
993
|
+
const ellipsed = `…/${base}`;
|
|
994
|
+
if (!dir || measure(ellipsed) > maxWidth) return fitKeepEnd(base, maxWidth, measure);
|
|
995
|
+
let lo = 0;
|
|
996
|
+
let hi = dir.length;
|
|
997
|
+
let best = ellipsed;
|
|
998
|
+
while (lo <= hi) {
|
|
999
|
+
const mid = (lo + hi) >> 1;
|
|
1000
|
+
const candidate = `${dir.slice(0, mid)}…/${base}`;
|
|
1001
|
+
if (measure(candidate) <= maxWidth) {
|
|
1002
|
+
best = candidate;
|
|
1003
|
+
lo = mid + 1;
|
|
1004
|
+
} else {
|
|
1005
|
+
hi = mid - 1;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return best;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function fitKeepEnd(text, maxWidth, measure) {
|
|
1012
|
+
if (measure(text) <= maxWidth) return text;
|
|
1013
|
+
if (measure('…') > maxWidth) return '';
|
|
1014
|
+
let lo = 0;
|
|
1015
|
+
let hi = text.length;
|
|
1016
|
+
let best = '…';
|
|
1017
|
+
while (lo <= hi) {
|
|
1018
|
+
const mid = (lo + hi) >> 1;
|
|
1019
|
+
const candidate = `…${text.slice(text.length - mid)}`;
|
|
1020
|
+
if (measure(candidate) <= maxWidth) {
|
|
1021
|
+
best = candidate;
|
|
1022
|
+
lo = mid + 1;
|
|
1023
|
+
} else {
|
|
1024
|
+
hi = mid - 1;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return best;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function FilePathLabel({ path }) {
|
|
1031
|
+
const ref = useRef(null);
|
|
1032
|
+
const [shown, setShown] = useState(path);
|
|
1033
|
+
useLayoutEffect(() => {
|
|
1034
|
+
const node = ref.current;
|
|
1035
|
+
if (!node) return undefined;
|
|
1036
|
+
function fit() {
|
|
1037
|
+
const max = node.clientWidth;
|
|
1038
|
+
if (max <= 0) return;
|
|
1039
|
+
const cs = getComputedStyle(node);
|
|
1040
|
+
const font = cs.font && cs.font !== '0px' ? cs.font : `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
1041
|
+
setShown(fitPathKeepBasename(path, max, font));
|
|
1042
|
+
}
|
|
1043
|
+
fit();
|
|
1044
|
+
const observer = new ResizeObserver(fit);
|
|
1045
|
+
observer.observe(node);
|
|
1046
|
+
return () => observer.disconnect();
|
|
1047
|
+
}, [path]);
|
|
1048
|
+
return (
|
|
1049
|
+
<span ref={ref} className={styles.filePickerPath} title={path}>
|
|
1050
|
+
{shown}
|
|
1051
|
+
</span>
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function pickerRowChrome(list) {
|
|
1056
|
+
if (!list) return 26;
|
|
1057
|
+
const box = getComputedStyle(list);
|
|
1058
|
+
const opt = list.querySelector('button');
|
|
1059
|
+
const op = opt ? getComputedStyle(opt) : null;
|
|
1060
|
+
return (
|
|
1061
|
+
(parseFloat(box.paddingLeft) || 0) +
|
|
1062
|
+
(parseFloat(box.paddingRight) || 0) +
|
|
1063
|
+
(parseFloat(box.borderLeftWidth) || 0) +
|
|
1064
|
+
(parseFloat(box.borderRightWidth) || 0) +
|
|
1065
|
+
(op ? (parseFloat(op.paddingLeft) || 0) + (parseFloat(op.paddingRight) || 0) : 16)
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function longestPathWidth(list, paths) {
|
|
1070
|
+
if (!list || !paths.length) return 0;
|
|
1071
|
+
const probe = document.createElement('span');
|
|
1072
|
+
probe.style.cssText = 'position:absolute;left:-9999px;white-space:nowrap;pointer-events:none;';
|
|
1073
|
+
const opt = list.querySelector('button');
|
|
1074
|
+
if (opt) {
|
|
1075
|
+
const s = getComputedStyle(opt);
|
|
1076
|
+
probe.style.font = s.font && s.font !== '0px' ? s.font : `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
1077
|
+
}
|
|
1078
|
+
list.appendChild(probe);
|
|
1079
|
+
let max = 0;
|
|
1080
|
+
for (const path of paths) {
|
|
1081
|
+
probe.textContent = path || 'None';
|
|
1082
|
+
max = Math.max(max, probe.offsetWidth);
|
|
1083
|
+
}
|
|
1084
|
+
probe.remove();
|
|
1085
|
+
return max;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// File path field. Closed chrome shows the basename; the open list shows full
|
|
1089
|
+
// paths. Callers pass the candidate paths — this does not decide what a sprite
|
|
1090
|
+
// or sound is. `allowEmpty` adds a None row that writes ''.
|
|
1091
|
+
export function FileField({
|
|
1092
|
+
label,
|
|
1093
|
+
value,
|
|
1094
|
+
onChange,
|
|
1095
|
+
files = [],
|
|
1096
|
+
allowEmpty = false,
|
|
1097
|
+
overridden,
|
|
1098
|
+
defaultValue,
|
|
1099
|
+
onReset,
|
|
1100
|
+
}) {
|
|
1101
|
+
const current = value ?? '';
|
|
1102
|
+
const [open, setOpen] = useState(false);
|
|
1103
|
+
const buttonRef = useRef(null);
|
|
1104
|
+
const listRef = useRef(null);
|
|
1105
|
+
const [pos, setPos] = useState(null);
|
|
1106
|
+
|
|
1107
|
+
const paths = [];
|
|
1108
|
+
if (allowEmpty) paths.push('');
|
|
1109
|
+
for (const path of files) {
|
|
1110
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
1111
|
+
}
|
|
1112
|
+
if (current && !paths.includes(current)) paths.splice(allowEmpty ? 1 : 0, 0, current);
|
|
1113
|
+
|
|
1114
|
+
useLayoutEffect(() => {
|
|
1115
|
+
if (!open || !buttonRef.current) return undefined;
|
|
1116
|
+
function place() {
|
|
1117
|
+
const anchor = buttonRef.current.getBoundingClientRect();
|
|
1118
|
+
const list = listRef.current;
|
|
1119
|
+
const margin = 8;
|
|
1120
|
+
const maxWidth = window.innerWidth - margin * 2;
|
|
1121
|
+
const needed = longestPathWidth(list, paths) + pickerRowChrome(list) + 4;
|
|
1122
|
+
const width = Math.min(Math.max(needed, anchor.width), maxWidth);
|
|
1123
|
+
// Grow left from the field if the paths need more than the control width.
|
|
1124
|
+
let left = anchor.right - width;
|
|
1125
|
+
if (left < margin) left = margin;
|
|
1126
|
+
if (left + width > window.innerWidth - margin) {
|
|
1127
|
+
left = Math.max(margin, window.innerWidth - width - margin);
|
|
1128
|
+
}
|
|
1129
|
+
const gap = 4;
|
|
1130
|
+
const height = list?.getBoundingClientRect().height ?? 0;
|
|
1131
|
+
let top = anchor.bottom + gap;
|
|
1132
|
+
if (height && top + height > window.innerHeight - margin) {
|
|
1133
|
+
top = Math.max(margin, anchor.top - gap - height);
|
|
1134
|
+
}
|
|
1135
|
+
setPos({ top, left, width });
|
|
1136
|
+
}
|
|
1137
|
+
place();
|
|
1138
|
+
window.addEventListener('resize', place);
|
|
1139
|
+
window.addEventListener('scroll', place, true);
|
|
1140
|
+
return () => {
|
|
1141
|
+
window.removeEventListener('resize', place);
|
|
1142
|
+
window.removeEventListener('scroll', place, true);
|
|
1143
|
+
};
|
|
1144
|
+
}, [open, paths.length]);
|
|
1145
|
+
|
|
1146
|
+
useEffect(() => {
|
|
1147
|
+
if (!open) return undefined;
|
|
1148
|
+
function onKeyDown(event) {
|
|
1149
|
+
if (event.key === 'Escape') setOpen(false);
|
|
1150
|
+
}
|
|
1151
|
+
function onPointerDown(event) {
|
|
1152
|
+
if (listRef.current?.contains(event.target) || buttonRef.current?.contains(event.target)) {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
setOpen(false);
|
|
1156
|
+
}
|
|
1157
|
+
window.addEventListener('keydown', onKeyDown);
|
|
1158
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
1159
|
+
return () => {
|
|
1160
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
1161
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
1162
|
+
};
|
|
1163
|
+
}, [open]);
|
|
1164
|
+
|
|
1165
|
+
return (
|
|
1166
|
+
<FieldRow
|
|
1167
|
+
label={label}
|
|
1168
|
+
overridden={overridden}
|
|
1169
|
+
defaultValue={defaultValue ? basenameOf(defaultValue) : defaultValue}
|
|
1170
|
+
onReset={onReset}>
|
|
1171
|
+
<button
|
|
1172
|
+
ref={buttonRef}
|
|
1173
|
+
type="button"
|
|
1174
|
+
className={cx(styles.select, styles.fileField)}
|
|
1175
|
+
aria-haspopup="listbox"
|
|
1176
|
+
aria-expanded={open}
|
|
1177
|
+
onClick={() => setOpen((next) => !next)}>
|
|
1178
|
+
<span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
|
|
1179
|
+
{current ? basenameOf(current) : 'None'}
|
|
1180
|
+
</span>
|
|
1181
|
+
</button>
|
|
1182
|
+
{open
|
|
1183
|
+
? createPortal(
|
|
1184
|
+
<div
|
|
1185
|
+
ref={listRef}
|
|
1186
|
+
className={styles.filePicker}
|
|
1187
|
+
role="listbox"
|
|
1188
|
+
aria-label={label}
|
|
1189
|
+
style={pos ?? { visibility: 'hidden' }}>
|
|
1190
|
+
{paths.map((path) => {
|
|
1191
|
+
const selected = path === current;
|
|
1192
|
+
return (
|
|
1193
|
+
<button
|
|
1194
|
+
key={path || '__none'}
|
|
1195
|
+
type="button"
|
|
1196
|
+
role="option"
|
|
1197
|
+
aria-selected={selected}
|
|
1198
|
+
className={cx(
|
|
1199
|
+
styles.filePickerOption,
|
|
1200
|
+
selected && styles.filePickerOptionSelected,
|
|
1201
|
+
!path && styles.fileFieldEmpty
|
|
1202
|
+
)}
|
|
1203
|
+
title={path || 'None'}
|
|
1204
|
+
onClick={() => {
|
|
1205
|
+
onChange(path);
|
|
1206
|
+
setOpen(false);
|
|
1207
|
+
}}>
|
|
1208
|
+
{path ? <FilePathLabel path={path} /> : 'None'}
|
|
1209
|
+
</button>
|
|
1210
|
+
);
|
|
1211
|
+
})}
|
|
1212
|
+
</div>,
|
|
1213
|
+
document.body
|
|
1214
|
+
)
|
|
1215
|
+
: null}
|
|
1216
|
+
</FieldRow>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
@@ -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 {
|
|
@@ -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()));
|