time for a snapshot
This commit is contained in:
Vendored
-1
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"bun.runtime": "~/.bun/bin/bun",
|
|
||||||
"bun.debugTerminal.enabled": true,
|
"bun.debugTerminal.enabled": true,
|
||||||
"bun.debugTerminal.stopOnEntry": false,
|
"bun.debugTerminal.stopOnEntry": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"a11y": "off",
|
"a11y": "off",
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "off"
|
"noNonNullAssertion": "off",
|
||||||
|
"useConst": {
|
||||||
|
"fix": "none",
|
||||||
|
"level": "warn"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noTsIgnore": "off",
|
"noTsIgnore": "off",
|
||||||
|
|||||||
@@ -4,17 +4,15 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nage",
|
"name": "nage",
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"lz-string": "latest",
|
"lz-string": "latest",
|
||||||
"solid-js": "latest",
|
"solid-js": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
"vite": "latest",
|
"vite": "latest",
|
||||||
"vite-plugin-solid": "latest",
|
"vite-plugin-solid": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "latest",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -54,29 +52,29 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
"@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="],
|
"@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
|
||||||
|
|
||||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="],
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
|
||||||
|
|
||||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="],
|
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="],
|
||||||
|
|
||||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="],
|
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="],
|
||||||
|
|
||||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="],
|
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="],
|
||||||
|
|
||||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="],
|
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="],
|
||||||
|
|
||||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="],
|
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="],
|
||||||
|
|
||||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="],
|
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="],
|
||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
@@ -88,43 +86,43 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
|
||||||
|
|
||||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
|
||||||
|
|
||||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
|
||||||
|
|
||||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
|
||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
|
||||||
|
|
||||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
|
||||||
|
|
||||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
@@ -138,6 +136,46 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||||
|
|
||||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
||||||
|
|
||||||
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||||
@@ -220,19 +258,19 @@
|
|||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
||||||
|
|
||||||
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
|
"rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
|
"seroval": ["seroval@1.5.5", "", {}, "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw=="],
|
||||||
|
|
||||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
"seroval-plugins": ["seroval-plugins@1.5.5", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg=="],
|
||||||
|
|
||||||
"solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="],
|
"solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="],
|
||||||
|
|
||||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||||
|
|
||||||
@@ -242,13 +280,13 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
|
"vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="],
|
||||||
|
|
||||||
"vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
"vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
||||||
|
|
||||||
@@ -257,5 +295,7 @@
|
|||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -137,18 +137,22 @@ function sweptAABB(
|
|||||||
positionB: Vector,
|
positionB: Vector,
|
||||||
delta: number,
|
delta: number,
|
||||||
): null | number {
|
): null | number {
|
||||||
const ax = positionA.x + colliderA.offset.x;
|
// setup the "real" position for collider a
|
||||||
const ay = positionA.y + colliderA.offset.y;
|
const ax = positionA.x + colliderA.offset.x; // calc the x position of the collider a
|
||||||
const bx = positionB.x + colliderB.offset.x;
|
const ay = positionA.y + colliderA.offset.y; // calc the y position of the collider a
|
||||||
const by = positionB.y + colliderB.offset.y;
|
// setup the "real" position for collider b
|
||||||
|
const bx = positionB.x + colliderB.offset.x; // calc the x positoin of the collider b
|
||||||
|
const by = positionB.y + colliderB.offset.y; // calc the y position of the collider b
|
||||||
|
|
||||||
inflAABB.x = bx - colliderA.width;
|
// setup the inflated AABB
|
||||||
inflAABB.y = by - colliderA.height;
|
inflAABB.x = bx - colliderA.width; // calc the x position of the inflated AABB
|
||||||
inflAABB.width = colliderB.width + colliderA.width;
|
inflAABB.y = by - colliderA.height; // calc the y position of the inflated AABB
|
||||||
inflAABB.height = colliderB.height + colliderA.height;
|
inflAABB.width = colliderB.width + colliderA.width; // calc the width of the inflated AABB
|
||||||
|
inflAABB.height = colliderB.height + colliderA.height; // calc the height of the inflated AABB
|
||||||
|
|
||||||
const vx = velocityA.x * delta;
|
// full-frame displacement for collider a
|
||||||
const vy = velocityA.y * delta;
|
const vx = velocityA.x * delta; // scale x by delta for collider a
|
||||||
|
const vy = velocityA.y * delta; // scale y by delta for collider a
|
||||||
|
|
||||||
let txNear = (inflAABB.x - ax) / vx;
|
let txNear = (inflAABB.x - ax) / vx;
|
||||||
let txFar = (inflAABB.x + inflAABB.width - ax) / vx;
|
let txFar = (inflAABB.x + inflAABB.width - ax) / vx;
|
||||||
|
|||||||
+5
-7
@@ -7,17 +7,15 @@
|
|||||||
"preview": "vite build && vite preview",
|
"preview": "vite build && vite preview",
|
||||||
"check": "biome check --write"
|
"check": "biome check --write"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@biomejs/biome": "2.5.0",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/bun": "1.3.14",
|
"@types/bun": "1.3.14",
|
||||||
"lz-string": "1.5.0",
|
"lz-string": "1.5.0",
|
||||||
"solid-js": "1.9.13",
|
"solid-js": "1.9.14",
|
||||||
"vite": "8.0.16",
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.1.4",
|
||||||
"vite-plugin-solid": "2.11.12"
|
"vite-plugin-solid": "2.11.12"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "6.0.3"
|
|
||||||
},
|
|
||||||
"imports": {
|
"imports": {
|
||||||
"#/": "./*"
|
"#/": "./*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
"types": ["bun"],
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxImportSource": "solid-js",
|
"jsxImportSource": "solid-js",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Physics Kernel Workshop
|
||||||
|
|
||||||
|
A from-scratch, test-driven path to a working 2D **swept collision** engine —
|
||||||
|
the same kind that lives in `nage`, but stripped of rendering, scenes, ECS, and
|
||||||
|
every other distraction. Just the physics, one tiny step at a time.
|
||||||
|
|
||||||
|
## How it works (LeetCode style)
|
||||||
|
|
||||||
|
Each folder under `steps/` is one self-contained kata:
|
||||||
|
|
||||||
|
- `README.md` — the concept (taught from zero) and the task.
|
||||||
|
- a stub file — the function(s) **you** implement. They start by `throw`ing.
|
||||||
|
- a `*.test.ts` file — the validator. Red until you implement it, green when you're done.
|
||||||
|
- sometimes a `given.ts` — prerequisites from earlier steps, already finished, so
|
||||||
|
you only ever implement the **one new idea** of this step.
|
||||||
|
|
||||||
|
### Run one step
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/01-vectors
|
||||||
|
```
|
||||||
|
|
||||||
|
Red (failing) is the starting state. Implement the stub until it goes green, then
|
||||||
|
ping me and I'll validate + unlock the next batch.
|
||||||
|
|
||||||
|
> Skip nothing silently, but blast through what you already know — the early steps
|
||||||
|
> are deliberately trivial so the test loop becomes muscle memory before the hard
|
||||||
|
> rungs.
|
||||||
|
|
||||||
|
## The ladder
|
||||||
|
|
||||||
|
Each rung is a concept that the real `nage` physics depends on. We climb until
|
||||||
|
the top rung *is* a working engine.
|
||||||
|
|
||||||
|
**Batch 1 — foundations (these files exist now):**
|
||||||
|
- [x] `01-vectors` — vectors as pairs of numbers: add, sub, scale
|
||||||
|
- [x] `02-vectors-length` — length, normalize (and the zero-vector trap), dot
|
||||||
|
- [x] `03-integration` — `pos += vel * delta`, and why framerate independence matters
|
||||||
|
- [x] `04-aabb` — axis-aligned boxes, point-in-box, box overlap (the *discrete* test)
|
||||||
|
- [x] `05-sweep-1d` — the **entry/exit time** of a moving point against an interval. The seed of everything.
|
||||||
|
|
||||||
|
**Batch 2 — the swept core (these files exist now):**
|
||||||
|
- [x] `06-ray-vs-aabb` — combine two 1D sweeps into one: ray vs box, with the surface **normal**
|
||||||
|
- [x] `07-swept-aabb` — the **Minkowski** trick: shrink the moving box to a point, reuse step 06
|
||||||
|
|
||||||
|
**Batch 3 — response (these files exist now):**
|
||||||
|
- [x] `08-resolve` — stop at the moment of contact (`t`), not after
|
||||||
|
- [x] `09-slide` — subtract the into-the-wall part of velocity and keep going along the wall
|
||||||
|
|
||||||
|
**Batch 4 — the real loop + payoff (these files exist now):**
|
||||||
|
- [x] `10-move-and-slide` — the full loop: multiple obstacles, iteration cap (relative velocity explained)
|
||||||
|
- [x] `11-capstone` — a canvas demo (no test — just run it and play)
|
||||||
|
|
||||||
|
When step 11 is green you'll have re-derived your own engine's heart — and walking
|
||||||
|
back into `sweptAABB` should feel like reading your own handwriting again.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Step 01 — Vectors
|
||||||
|
|
||||||
|
> You know this cold. The point of this step is to make the red→green loop
|
||||||
|
> automatic, not to teach you arithmetic. Blast through it.
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
A **vector** here is nothing mystical: a pair of numbers `(x, y)`. We use the
|
||||||
|
same value to mean two different things depending on context:
|
||||||
|
|
||||||
|
- a **position** — a point in the world.
|
||||||
|
- a **displacement / velocity** — an arrow: "move this much in x, this much in y."
|
||||||
|
|
||||||
|
That's it. All of 2D physics is built on adding, subtracting, and scaling these
|
||||||
|
pairs.
|
||||||
|
|
||||||
|
- `add(a, b)` → `(a.x + b.x, a.y + b.y)` — apply an arrow to a point.
|
||||||
|
- `sub(a, b)` → `(a.x - b.x, a.y - b.y)` — the arrow that points *from b to a*.
|
||||||
|
- `scale(a, s)` → `(a.x * s, a.y * s)` — make an arrow longer/shorter.
|
||||||
|
|
||||||
|
> Note: we return **new** objects (pure functions) here for clarity. Your real
|
||||||
|
> engine mutates vectors in place to avoid garbage collection — that's a speed
|
||||||
|
> optimization we'll deliberately ignore until the very end.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `vec`, `add`, `sub`, and `scale` in `vector.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/01-vectors
|
||||||
|
```
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { add, scale, sub, vec } from "./vector.ts";
|
||||||
|
|
||||||
|
test("vec builds a vector", () => {
|
||||||
|
expect(vec(2, 3)).toEqual({ x: 2, y: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add is component-wise", () => {
|
||||||
|
expect(add(vec(1, 2), vec(3, 4))).toEqual({ x: 4, y: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sub is component-wise", () => {
|
||||||
|
expect(sub(vec(5, 5), vec(1, 2))).toEqual({ x: 4, y: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scale multiplies both components", () => {
|
||||||
|
expect(scale(vec(2, 3), 2)).toEqual({ x: 4, y: 6 });
|
||||||
|
expect(scale(vec(2, 3), 0)).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inputs are not mutated (pure functions)", () => {
|
||||||
|
const a = vec(1, 2);
|
||||||
|
add(a, vec(3, 4));
|
||||||
|
expect(a).toEqual({ x: 1, y: 2 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
|
||||||
|
/** Build a vector from two numbers. */
|
||||||
|
export function vec(x: number, y: number): Vec {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a + b (component-wise) */
|
||||||
|
export function add(a: Vec, b: Vec): Vec {
|
||||||
|
return {
|
||||||
|
x: a.x + b.x,
|
||||||
|
y: a.y + b.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a - b (component-wise) */
|
||||||
|
export function sub(a: Vec, b: Vec): Vec {
|
||||||
|
return {
|
||||||
|
x: a.x - b.x,
|
||||||
|
y: a.y - b.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a scaled by the scalar s */
|
||||||
|
export function scale(a: Vec, s: number): Vec {
|
||||||
|
return {
|
||||||
|
x: a.x * s,
|
||||||
|
y: a.y * s,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Step 02 — Length, Normalize, Dot
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
### Length (magnitude)
|
||||||
|
|
||||||
|
An arrow `(x, y)` has a length: how far it reaches. Pythagoras:
|
||||||
|
`length = sqrt(x*x + y*y)`. A velocity's length is its **speed**.
|
||||||
|
|
||||||
|
### Normalize
|
||||||
|
|
||||||
|
Often you want *just the direction* of an arrow, with length exactly 1 (a "unit
|
||||||
|
vector"). You get it by dividing the arrow by its own length:
|
||||||
|
`(x / len, y / len)`.
|
||||||
|
|
||||||
|
This is how your engine turns "the player pressed up+left" into a clean diagonal
|
||||||
|
direction before multiplying by a speed — so diagonal movement isn't faster than
|
||||||
|
straight movement.
|
||||||
|
|
||||||
|
> ⚠️ **The zero-vector trap.** What is the length of `(0, 0)`? Zero. What is
|
||||||
|
> `0 / 0`? `NaN`. If you normalize a zero vector naively, you poison it with
|
||||||
|
> `NaN`, and `NaN` spreads through every later calculation silently. A correct
|
||||||
|
> `normalize` must check for zero length and return `(0, 0)` instead of dividing.
|
||||||
|
> Remember this trap — it is exactly the kind of bug that hides in a real engine.
|
||||||
|
|
||||||
|
### Dot product
|
||||||
|
|
||||||
|
`dot(a, b) = a.x*b.x + a.y*b.y`. One number out of two vectors. For now just
|
||||||
|
implement it; in step 09 you'll learn that it answers "how much of arrow A points
|
||||||
|
along arrow B?" — the key to sliding along a wall.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `length`, `normalize` (zero-safe!), and `dot` in `mathv.ts`.
|
||||||
|
`vec`/`add`/`sub`/`scale` are already provided in `given.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/02-vectors-length
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Finished in step 01 — provided so you only implement the new ideas here.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
|
||||||
|
export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
|
||||||
|
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { vec } from "./given.ts";
|
||||||
|
import { dot, length, normalize } from "./mathv.ts";
|
||||||
|
|
||||||
|
test("length uses Pythagoras", () => {
|
||||||
|
expect(length(vec(3, 4))).toBe(5);
|
||||||
|
expect(length(vec(0, 0))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalize gives a unit vector in the same direction", () => {
|
||||||
|
const n = normalize(vec(3, 4));
|
||||||
|
expect(n.x).toBeCloseTo(0.6);
|
||||||
|
expect(n.y).toBeCloseTo(0.8);
|
||||||
|
expect(length(n)).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalize of the zero vector is (0,0), not NaN", () => {
|
||||||
|
const n = normalize(vec(0, 0));
|
||||||
|
expect(n).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(Number.isNaN(n.x)).toBe(false);
|
||||||
|
expect(Number.isNaN(n.y)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dot product", () => {
|
||||||
|
expect(dot(vec(1, 0), vec(0, 1))).toBe(0); // perpendicular
|
||||||
|
expect(dot(vec(2, 3), vec(4, 5))).toBe(23); // 8 + 15
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/** The length (magnitude) of the arrow. */
|
||||||
|
export function length(a: Vec): number {
|
||||||
|
return Math.sqrt(a.x * a.x + a.y * a.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unit-length vector pointing the same way as `a`.
|
||||||
|
* MUST return (0, 0) when `a` is the zero vector — do not divide by zero.
|
||||||
|
*/
|
||||||
|
export function normalize(a: Vec): Vec {
|
||||||
|
const len = length(a);
|
||||||
|
const x = a.x === 0 ? 0 : a.x / len;
|
||||||
|
const y = a.y === 0 ? 0 : a.y / len;
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dot product a·b. */
|
||||||
|
export function dot(a: Vec, b: Vec): number {
|
||||||
|
return a.x * b.x + a.y * b.y;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Step 03 — Integration (moving over time)
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
"Integration" sounds like calculus, but the version we need is one line. An
|
||||||
|
object has a **position** and a **velocity**. Each frame we advance the position
|
||||||
|
by the velocity, scaled by how much time passed:
|
||||||
|
|
||||||
|
```
|
||||||
|
newPosition = position + velocity * delta
|
||||||
|
```
|
||||||
|
|
||||||
|
That's `add(pos, scale(vel, delta))`. This single step is the beating heart of
|
||||||
|
every game's update loop.
|
||||||
|
|
||||||
|
### Why `delta`?
|
||||||
|
|
||||||
|
`delta` is the number of **milliseconds since the last frame**. Frames are not
|
||||||
|
evenly spaced — a busy frame takes longer. If you moved a fixed amount *per
|
||||||
|
frame* instead of *per millisecond*, your game would run faster on a fast
|
||||||
|
computer and slower on a slow one.
|
||||||
|
|
||||||
|
By storing velocity as **units-per-millisecond** and multiplying by `delta`, the
|
||||||
|
distance travelled over a given stretch of real time is the same no matter how
|
||||||
|
the frames are chopped up. That property is called **framerate independence**,
|
||||||
|
and one of the tests below proves it: moving once with `delta = 16` lands in the
|
||||||
|
same place as moving sixteen times with `delta = 1`.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `integrate(pos, vel, delta)` in `motion.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/03-integration
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Finished in earlier steps.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
|
||||||
|
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { vec } from "./given.ts";
|
||||||
|
import { integrate } from "./motion.ts";
|
||||||
|
|
||||||
|
test("integrate advances position by velocity * delta", () => {
|
||||||
|
// velocity 0.1 units/ms, 100ms => move 10 units
|
||||||
|
expect(integrate(vec(0, 0), vec(0.1, 0), 100)).toEqual({ x: 10, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero delta does not move", () => {
|
||||||
|
expect(integrate(vec(5, 5), vec(0.1, 0.1), 0)).toEqual({ x: 5, y: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("framerate independence: one big step == many small steps", () => {
|
||||||
|
const start = vec(0, 0);
|
||||||
|
const vel = vec(0.1, -0.05);
|
||||||
|
|
||||||
|
const oneBigStep = integrate(start, vel, 16);
|
||||||
|
|
||||||
|
let manySmall = start;
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
manySmall = integrate(manySmall, vel, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(manySmall.x).toBeCloseTo(oneBigStep.x);
|
||||||
|
expect(manySmall.y).toBeCloseTo(oneBigStep.y);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { add, scale, type Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance a position by a velocity over `delta` milliseconds.
|
||||||
|
* newPosition = position + velocity * delta
|
||||||
|
*/
|
||||||
|
export function integrate(pos: Vec, vel: Vec, delta: number): Vec {
|
||||||
|
return add(pos, scale(vel, delta));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Step 04 — AABB & the discrete overlap test
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
**AABB** = **A**xis-**A**ligned **B**ounding **B**ox: a rectangle whose sides are
|
||||||
|
parallel to the x and y axes (never rotated). They're cheap to test, which is why
|
||||||
|
almost every 2D engine — including yours — uses them as the base collision shape.
|
||||||
|
|
||||||
|
We represent one as a corner plus a size:
|
||||||
|
|
||||||
|
```
|
||||||
|
type AABB = { x, y, w, h } // (x,y) = top-left corner, w = width, h = height
|
||||||
|
```
|
||||||
|
|
||||||
|
So the box spans `x .. x+w` horizontally and `y .. y+h` vertically.
|
||||||
|
|
||||||
|
### Two boxes overlap when they overlap on BOTH axes
|
||||||
|
|
||||||
|
This is the key insight you'll reuse for the rest of the workshop. Think of each
|
||||||
|
box as a **shadow on the x-axis** and a **shadow on the y-axis**. Two boxes
|
||||||
|
intersect only if *both* pairs of shadows intersect:
|
||||||
|
|
||||||
|
```
|
||||||
|
overlapX: a.x < b.x + b.w AND b.x < a.x + a.w
|
||||||
|
overlapY: a.y < b.y + b.h AND b.y < a.y + a.h
|
||||||
|
overlap = overlapX AND overlapY
|
||||||
|
```
|
||||||
|
|
||||||
|
Hold onto "collision = the AND of two 1D tests." In a few steps you'll do the
|
||||||
|
exact same thing, but with **time** instead of space, and that's the whole trick
|
||||||
|
behind swept collision.
|
||||||
|
|
||||||
|
### The discrete trap (why this test alone isn't enough)
|
||||||
|
|
||||||
|
`aabbOverlap` only answers "are they overlapping *right now*?" If a fast object
|
||||||
|
jumps from one side of a thin wall to the other in a single frame, it never
|
||||||
|
overlaps the wall at any sampled instant — so this test says "no collision" and
|
||||||
|
the object tunnels straight through. Steps 05+ fix that by testing the *path*,
|
||||||
|
not the endpoints. Feel the gap here first; it's why everything after exists.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `pointInAABB` and `aabbOverlap` in `aabb.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/04-aabb
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { type AABB, aabbOverlap, pointInAABB } from "./aabb.ts";
|
||||||
|
|
||||||
|
const box: AABB = { x: 10, y: 10, w: 20, h: 20 }; // spans 10..30 in both axes
|
||||||
|
|
||||||
|
test("point inside the box", () => {
|
||||||
|
expect(pointInAABB({ x: 15, y: 15 }, box)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("point outside the box", () => {
|
||||||
|
expect(pointInAABB({ x: 5, y: 15 }, box)).toBe(false);
|
||||||
|
expect(pointInAABB({ x: 15, y: 35 }, box)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping boxes", () => {
|
||||||
|
const other: AABB = { x: 20, y: 20, w: 20, h: 20 };
|
||||||
|
expect(aabbOverlap(box, other)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("separated on the x axis only -> no overlap", () => {
|
||||||
|
const other: AABB = { x: 40, y: 10, w: 5, h: 20 };
|
||||||
|
expect(aabbOverlap(box, other)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("separated on the y axis only -> no overlap", () => {
|
||||||
|
const other: AABB = { x: 10, y: 40, w: 20, h: 5 };
|
||||||
|
expect(aabbOverlap(box, other)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
|
||||||
|
/** Axis-aligned box: (x,y) is the top-left corner, w/h are width/height. */
|
||||||
|
export type AABB = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
/** Is the point inside (or on the edge of) the box? */
|
||||||
|
export function pointInAABB(p: Vec, box: AABB): boolean {
|
||||||
|
return (
|
||||||
|
p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Do the two boxes overlap right now? (discrete test) */
|
||||||
|
export function aabbOverlap(a: AABB, b: AABB): boolean {
|
||||||
|
return (
|
||||||
|
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Step 05 — Sweeping in 1D (entry & exit time)
|
||||||
|
|
||||||
|
This is the seed of the whole engine. Get this one *in your bones* and the scary
|
||||||
|
2D `sweptAABB` becomes "do this twice and combine."
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
Forget 2D. Forget boxes. We have:
|
||||||
|
|
||||||
|
- a **point** sitting at position `p` on a number line,
|
||||||
|
- moving with velocity `v` — meaning over this one frame it travels a total of
|
||||||
|
`v` units (so at fraction `t` of the frame, it's at `p + v*t`, for `t` from 0 to 1),
|
||||||
|
- and a static **interval** `[min, max]` on that same line.
|
||||||
|
|
||||||
|
Question: **during this frame, for which `t` is the point inside `[min, max]`?**
|
||||||
|
|
||||||
|
### The slab math
|
||||||
|
|
||||||
|
The point reaches `min` when `p + v*t = min`, i.e. `t = (min - p) / v`.
|
||||||
|
Likewise it reaches `max` at `t = (max - p) / v`.
|
||||||
|
```
|
||||||
|
t1 = (min - p) / v
|
||||||
|
t2 = (max - p) / v
|
||||||
|
```
|
||||||
|
|
||||||
|
If `v` is **negative** (moving left), the point hits `max` *before* `min`, so
|
||||||
|
`t1 > t2`. We always want `entry` to be the smaller and `exit` the larger, so
|
||||||
|
**swap them if they're out of order**. Then:
|
||||||
|
|
||||||
|
- `entry` = the time the point *enters* the interval,
|
||||||
|
- `exit` = the time it *leaves*.
|
||||||
|
|
||||||
|
> These can be negative or greater than 1 — that just means the crossing happens
|
||||||
|
> before this frame started or after it ends. Don't clamp here; the caller (step
|
||||||
|
> 06/08) decides whether `entry` falls within `[0, 1]`. Keeping the raw numbers
|
||||||
|
> is what lets us combine axes later.
|
||||||
|
|
||||||
|
### The `v == 0` edge case
|
||||||
|
|
||||||
|
If the point isn't moving (`v == 0`), it never *crosses* an edge — dividing by
|
||||||
|
zero is meaningless. Instead: it's either already inside the interval for the
|
||||||
|
whole frame, or never. So:
|
||||||
|
|
||||||
|
- if `min <= p <= max`: it's inside the entire time → `entry = -Infinity`,
|
||||||
|
`exit = +Infinity`.
|
||||||
|
- otherwise: it never overlaps → return `null`.
|
||||||
|
|
||||||
|
(Those infinities are deliberate: in 2D they let a non-moving axis say "I'm not
|
||||||
|
the axis that limits the collision," without breaking the `max`/`min` combine
|
||||||
|
step. You'll see why in step 06.)
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `sweepInterval(p, v, min, max)` in `sweep1d.ts`. Return
|
||||||
|
`{ entry, exit }`, or `null` only in the not-moving-and-outside case.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/05-sweep-1d
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { sweepInterval } from "./sweep1d.ts";
|
||||||
|
|
||||||
|
test("moving right into the interval", () => {
|
||||||
|
// point at 0, moves +10 this frame, interval [5, 8]
|
||||||
|
// enters at (5-0)/10 = 0.5, exits at (8-0)/10 = 0.8
|
||||||
|
const s = sweepInterval(0, 10, 5, 8)!;
|
||||||
|
expect(s.entry).toBeCloseTo(0.5);
|
||||||
|
expect(s.exit).toBeCloseTo(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moving left: entry/exit are swapped into order", () => {
|
||||||
|
// point at 10, moves -10, interval [5, 8]
|
||||||
|
// crosses 8 at t=0.2, crosses 5 at t=0.5 -> entry 0.2, exit 0.5
|
||||||
|
const s = sweepInterval(10, -10, 5, 8)!;
|
||||||
|
expect(s.entry).toBeCloseTo(0.2);
|
||||||
|
expect(s.exit).toBeCloseTo(0.5);
|
||||||
|
expect(s.entry).toBeLessThanOrEqual(s.exit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not moving but already inside -> infinite span", () => {
|
||||||
|
const s = sweepInterval(6, 0, 5, 8)!;
|
||||||
|
expect(s.entry).toBe(-Infinity);
|
||||||
|
expect(s.exit).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not moving and outside -> null", () => {
|
||||||
|
expect(sweepInterval(2, 0, 5, 8)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type Span = { entry: number; exit: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When is a point at `p`, moving by `v` over the frame, inside [min, max]?
|
||||||
|
*
|
||||||
|
* Returns the entry/exit times (t, where the position is p + v*t).
|
||||||
|
* `entry` is always <= `exit`. Values may be < 0 or > 1.
|
||||||
|
*
|
||||||
|
* Special case: if v === 0, return {entry:-Infinity, exit:Infinity} when the
|
||||||
|
* point is already inside [min, max], otherwise return null.
|
||||||
|
*/
|
||||||
|
export function sweepInterval(
|
||||||
|
p: number,
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): Span | null {
|
||||||
|
if (v === 0) {
|
||||||
|
return isInBetween(p, min, max)
|
||||||
|
? {
|
||||||
|
entry: -Infinity,
|
||||||
|
exit: +Infinity,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1 = (min - p) / v;
|
||||||
|
let t2 = (max - p) / v;
|
||||||
|
|
||||||
|
if (t1 > t2) {
|
||||||
|
[t1, t2] = [t2, t1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry: t1,
|
||||||
|
exit: t2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInBetween(p: number, min: number, max: number): boolean {
|
||||||
|
return min <= p && p <= max;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Step 06 — Ray vs AABB (two sweeps become one hit)
|
||||||
|
|
||||||
|
This is where step 04 ("collision = the AND of two 1D tests") and step 05 ("the
|
||||||
|
entry/exit time of one sweep") finally fuse. A **moving point vs a static box**.
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
A point at `p` moves by `v` over the frame. A static box has a left/right edge
|
||||||
|
(its x-interval) and a top/bottom edge (its y-interval). The point is inside the
|
||||||
|
**box** only while it's inside the x-interval **and** the y-interval *at the same
|
||||||
|
time*.
|
||||||
|
|
||||||
|
So run `sweepInterval` twice:
|
||||||
|
|
||||||
|
```
|
||||||
|
spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w) // the x-edges
|
||||||
|
spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h) // the y-edges
|
||||||
|
```
|
||||||
|
|
||||||
|
Each gives you a time-window `[entry, exit]` during which the point is inside
|
||||||
|
*that one axis's* strip. You're inside the box during the **overlap of the two
|
||||||
|
windows**:
|
||||||
|
|
||||||
|
```
|
||||||
|
entry = max(spanX.entry, spanY.entry) // inside the box once you're inside the LAST axis
|
||||||
|
exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIRST axis
|
||||||
|
```
|
||||||
|
|
||||||
|
Read those two lines until they feel obvious — they're the whole algorithm:
|
||||||
|
|
||||||
|
- You're only truly *inside the box* once you've entered **both** strips, so the
|
||||||
|
real entry is the **later** of the two entries → `max`.
|
||||||
|
- You **leave** the box the instant you exit **either** strip → the **earlier**
|
||||||
|
exit → `min`.
|
||||||
|
|
||||||
|
### When is there NO hit?
|
||||||
|
|
||||||
|
1. **A span is `null`** — on some axis the point isn't moving and is already
|
||||||
|
outside that strip. It can never be inside the box. Return `null` immediately.
|
||||||
|
2. **`entry > exit`** — the two windows never overlap. The point is inside one
|
||||||
|
strip, then the other, but never both at once. That's the classic "flies past
|
||||||
|
the corner" miss.
|
||||||
|
3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not *during this
|
||||||
|
frame* (it's entirely in the future, or entirely in the past). Not our problem
|
||||||
|
this frame.
|
||||||
|
|
||||||
|
### The normal (which wall did we hit?)
|
||||||
|
|
||||||
|
When you do collide, you also want to know **which face** you hit, so the response
|
||||||
|
later can push you back the right way. That's the `normal` — a unit vector
|
||||||
|
pointing out of the surface you struck.
|
||||||
|
|
||||||
|
The trick: **the axis you entered *last* is the axis you actually hit.** Compare
|
||||||
|
the two entry times — whichever is larger is the blocking axis:
|
||||||
|
|
||||||
|
- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right face).
|
||||||
|
The normal is horizontal, pointing back against your x-motion:
|
||||||
|
`normal = { x: v.x > 0 ? -1 : 1, y: 0 }`.
|
||||||
|
- otherwise → you hit a **horizontal** wall (top/bottom). The normal is vertical:
|
||||||
|
`normal = { x: 0, y: v.y > 0 ? -1 : 1 }`.
|
||||||
|
|
||||||
|
(Moving right and hitting something → the surface pushes you left → normal `-1`.
|
||||||
|
That sign rule is all there is to it.)
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `rayVsAABB(p, v, box)` in `ray.ts`. Return `{ time, normal }` for the
|
||||||
|
entry, or `null` for any of the three no-hit cases. `sweepInterval` is provided
|
||||||
|
in `given.ts` — **reuse it**, don't re-derive it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/06-ray-vs-aabb
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Finished in earlier steps — reuse, don't rewrite.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
|
||||||
|
export type AABB = { x: number; y: number; w: number; h: number };
|
||||||
|
export type Span = { entry: number; exit: number };
|
||||||
|
|
||||||
|
/** Step 05, finished. */
|
||||||
|
export function sweepInterval(
|
||||||
|
p: number,
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): Span | null {
|
||||||
|
if (v === 0) {
|
||||||
|
return min <= p && p <= max
|
||||||
|
? { entry: -Infinity, exit: Infinity }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1 = (min - p) / v;
|
||||||
|
let t2 = (max - p) / v;
|
||||||
|
if (t1 > t2) {
|
||||||
|
[t1, t2] = [t2, t1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entry: t1, exit: t2 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { type AABB, vec } from "./given.ts";
|
||||||
|
import { rayVsAABB } from "./ray.ts";
|
||||||
|
|
||||||
|
test("direct hit moving right -> normal points left", () => {
|
||||||
|
// point at (0,5) moving +10 in x; box spans x:[5,8], y:[0,10]
|
||||||
|
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
|
||||||
|
const hit = rayVsAABB(vec(0, 5), vec(10, 0), box)!;
|
||||||
|
expect(hit.time).toBeCloseTo(0.5); // reaches x=5 at half the frame
|
||||||
|
expect(hit.normal).toEqual({ x: -1, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes above the box (not moving in y, outside in y) -> null", () => {
|
||||||
|
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
|
||||||
|
expect(rayVsAABB(vec(0, 20), vec(10, 0), box)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagonal hit where y is the blocking axis -> vertical normal", () => {
|
||||||
|
// box x:[5,15], y:[8,18]; you enter x at t=0.5 but y only at t=0.8,
|
||||||
|
// so the real entry is 0.8 and the wall you hit is horizontal (top).
|
||||||
|
const box: AABB = { x: 5, y: 8, w: 10, h: 10 };
|
||||||
|
const hit = rayVsAABB(vec(0, 0), vec(10, 10), box)!;
|
||||||
|
expect(hit.time).toBeCloseTo(0.8);
|
||||||
|
expect(hit.normal).toEqual({ x: 0, y: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flies past the corner (windows never overlap) -> null", () => {
|
||||||
|
// inside x during [0.5,0.8], inside y during [0,0.1] — never both at once
|
||||||
|
const box: AABB = { x: 5, y: 0, w: 3, h: 1 };
|
||||||
|
expect(rayVsAABB(vec(0, 0), vec(10, 10), box)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collision is real but lands after this frame -> null", () => {
|
||||||
|
// too slow: reaches the box at t=5, outside [0,1)
|
||||||
|
const box: AABB = { x: 5, y: 0, w: 2, h: 10 };
|
||||||
|
expect(rayVsAABB(vec(0, 0), vec(1, 0), box)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { type AABB, type Span, sweepInterval, type Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/** A collision: the fraction of the frame at impact, and the surface normal. */
|
||||||
|
export type Hit = { time: number; normal: Vec };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A point at `p` moving by `v` over this frame, against a static box.
|
||||||
|
* Returns the entry Hit, or null if there is no collision this frame.
|
||||||
|
*
|
||||||
|
* Reuse sweepInterval twice (x and y), then combine:
|
||||||
|
* entry = max of the two entries, exit = min of the two exits.
|
||||||
|
*/
|
||||||
|
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
|
||||||
|
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||||||
|
if (spanX === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||||||
|
if (spanY === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span: Span = {
|
||||||
|
entry: Math.max(spanX.entry, spanY.entry),
|
||||||
|
exit: Math.min(spanX.exit, spanY.exit),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (span.entry > span.exit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (span.entry >= 1 || span.exit <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = span.entry;
|
||||||
|
const normal = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (spanX.entry > spanY.entry) {
|
||||||
|
normal.x = v.x > 0 ? -1 : 1;
|
||||||
|
} else {
|
||||||
|
normal.y = v.y > 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
normal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Step 07 — Swept AABB (the Minkowski trick)
|
||||||
|
|
||||||
|
Step 06 handled a moving **point** vs a box. But in a real game the thing that
|
||||||
|
moves is a **box** (the player), not a point. This step turns "moving box vs box"
|
||||||
|
into "moving point vs box" so you can reuse step 06 *unchanged*. That conversion
|
||||||
|
is the single cleverest idea in the whole engine.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Box A (the player) sits at corner `(a.x, a.y)` with size `a.w × a.h`, and moves by
|
||||||
|
`v` this frame. Box B (a wall) is static. When do they touch?
|
||||||
|
|
||||||
|
It's fiddly because *both* shapes have size. You'd have to track four edges of A
|
||||||
|
against four edges of B. Ugh.
|
||||||
|
|
||||||
|
## The trick: grow B, shrink A to a point
|
||||||
|
|
||||||
|
Watch what "just touching" means on the x-axis. A spans `[a.x, a.x + a.w]`, B
|
||||||
|
spans `[b.x, b.x + b.w]`. They overlap when:
|
||||||
|
|
||||||
|
```
|
||||||
|
a.x < b.x + b.w AND b.x < a.x + a.w
|
||||||
|
```
|
||||||
|
|
||||||
|
Rearrange the second one (`b.x - a.w < a.x`) and you get a statement purely about
|
||||||
|
**`a.x`**, the corner of A:
|
||||||
|
|
||||||
|
```
|
||||||
|
b.x - a.w < a.x < b.x + b.w
|
||||||
|
```
|
||||||
|
|
||||||
|
Read that: A's *corner* `a.x` behaves exactly like a **point** sliding inside a
|
||||||
|
**wider interval** — one that starts `a.w` earlier and is `a.w` longer than B.
|
||||||
|
The same happens on y with `a.h`.
|
||||||
|
|
||||||
|
So: **dump all of A's size onto B, and A collapses to just its corner point.**
|
||||||
|
|
||||||
|
```
|
||||||
|
inflated = {
|
||||||
|
x: b.x - a.w, // push the left edge out by A's width
|
||||||
|
y: b.y - a.h, // push the top edge out by A's height
|
||||||
|
w: b.w + a.w, // grow width by A's width
|
||||||
|
h: b.h + a.h, // grow height by A's height
|
||||||
|
}
|
||||||
|
point = { x: a.x, y: a.y } // A is now just its corner
|
||||||
|
```
|
||||||
|
|
||||||
|
This grown box is the **Minkowski sum** of B with A. And "does this point, moving
|
||||||
|
by `v`, hit `inflated`?" is *exactly* `rayVsAABB` from step 06. You're done in
|
||||||
|
three lines.
|
||||||
|
|
||||||
|
> Sanity picture: player box 2 wide with its right edge at x=2, wall left edge at
|
||||||
|
> x=5 → real gap is 3. Inflate: `inflated.x = 5 - 2 = 3`, and the player's corner
|
||||||
|
> sits at x=0, so the corner-to-inflated-edge gap is also 3. Same answer, simpler
|
||||||
|
> shape. The inflation *bakes A's size into the wall* so the corner can pretend to
|
||||||
|
> be a point.
|
||||||
|
|
||||||
|
This is the heart of your real engine's `sweptAABB` — the `inflAABB` it builds is
|
||||||
|
this very inflated box, and `(ax, ay)` is this corner point.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `sweptAABB(a, v, b)` in `swept.ts`: build the inflated box, then call
|
||||||
|
the provided `rayVsAABB` (finished, in `given.ts`). Return its `Hit | null`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/07-swept-aabb
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Finished in earlier steps — reuse, don't rewrite.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
|
||||||
|
export type AABB = { x: number; y: number; w: number; h: number };
|
||||||
|
export type Span = { entry: number; exit: number };
|
||||||
|
export type Hit = { time: number; normal: Vec };
|
||||||
|
|
||||||
|
/** Step 05, finished. */
|
||||||
|
export function sweepInterval(
|
||||||
|
p: number,
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): Span | null {
|
||||||
|
if (v === 0) {
|
||||||
|
return min <= p && p <= max
|
||||||
|
? { entry: -Infinity, exit: Infinity }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1 = (min - p) / v;
|
||||||
|
let t2 = (max - p) / v;
|
||||||
|
if (t1 > t2) {
|
||||||
|
[t1, t2] = [t2, t1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entry: t1, exit: t2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 06, finished. A moving point vs a static box. */
|
||||||
|
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
|
||||||
|
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||||||
|
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||||||
|
if (spanX === null || spanY === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = Math.max(spanX.entry, spanY.entry);
|
||||||
|
const exit = Math.min(spanX.exit, spanY.exit);
|
||||||
|
if (entry > exit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry >= 1 || exit <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normal =
|
||||||
|
spanX.entry > spanY.entry
|
||||||
|
? { x: v.x > 0 ? -1 : 1, y: 0 }
|
||||||
|
: { x: 0, y: v.y > 0 ? -1 : 1 };
|
||||||
|
|
||||||
|
return { time: entry, normal };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { type AABB, vec } from "./given.ts";
|
||||||
|
import { sweptAABB } from "./swept.ts";
|
||||||
|
|
||||||
|
test("box moving right into a wall -> hits at the real gap", () => {
|
||||||
|
// A right edge at x=2, B left edge at x=5 -> gap 3, speed 10 -> t=0.3
|
||||||
|
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||||
|
const b: AABB = { x: 5, y: 0, w: 2, h: 2 };
|
||||||
|
const hit = sweptAABB(a, vec(10, 0), b)!;
|
||||||
|
expect(hit.time).toBeCloseTo(0.3);
|
||||||
|
expect(hit.normal).toEqual({ x: -1, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("box passes below the wall -> null", () => {
|
||||||
|
const a: AABB = { x: 0, y: 10, w: 2, h: 2 };
|
||||||
|
const b: AABB = { x: 5, y: 0, w: 2, h: 2 };
|
||||||
|
expect(sweptAABB(a, vec(10, 0), b)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("box moving left into a wall -> normal points right", () => {
|
||||||
|
// A left edge at x=10, B right edge at x=2 -> gap 8, speed 10 -> t=0.8
|
||||||
|
const a: AABB = { x: 10, y: 0, w: 2, h: 2 };
|
||||||
|
const b: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||||
|
const hit = sweptAABB(a, vec(-10, 0), b)!;
|
||||||
|
expect(hit.time).toBeCloseTo(0.8);
|
||||||
|
expect(hit.normal).toEqual({ x: 1, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagonal where y blocks first -> horizontal normal", () => {
|
||||||
|
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||||
|
const b: AABB = { x: 5, y: 8, w: 2, h: 2 };
|
||||||
|
const hit = sweptAABB(a, vec(10, 10), b)!;
|
||||||
|
expect(hit.time).toBeCloseTo(0.6);
|
||||||
|
expect(hit.normal).toEqual({ x: 0, y: -1 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { type AABB, type Hit, rayVsAABB, type Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A moving box `a` (velocity `v` over this frame) vs a static box `b`.
|
||||||
|
*
|
||||||
|
* Inflate `b` by `a`'s size (Minkowski sum), collapse `a` to its corner point,
|
||||||
|
* then reuse rayVsAABB. Return its Hit, or null for no collision this frame.
|
||||||
|
*/
|
||||||
|
export function sweptAABB(a: AABB, v: Vec, b: AABB): Hit | null {
|
||||||
|
const inflated: AABB = {
|
||||||
|
x: b.x - a.w,
|
||||||
|
y: b.y - a.h,
|
||||||
|
w: b.w + a.w,
|
||||||
|
h: b.h + a.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
const point: Vec = { x: a.x, y: a.y };
|
||||||
|
|
||||||
|
return rayVsAABB(point, v, inflated);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Step 08 — Resolve (move *to* the wall, not through it)
|
||||||
|
|
||||||
|
Detection is done. Now for **response** — actually reacting to the hit. This first
|
||||||
|
half is almost embarrassingly small, but it introduces the idea the whole loop
|
||||||
|
(step 10) is built on: **the frame isn't all-or-nothing.**
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
`sweptAABB` hands you a `Hit` with a `time` between 0 and 1 — the fraction of the
|
||||||
|
frame at which you'd collide. So instead of moving the full displacement `v`
|
||||||
|
(which would bury you inside the wall), you move only the part of it that happens
|
||||||
|
*before* impact:
|
||||||
|
|
||||||
|
```
|
||||||
|
no hit → newPos = pos + v (nothing in the way: take the whole move)
|
||||||
|
hit at t → newPos = pos + v * t (stop exactly at the contact point)
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it — you already have `add` and `scale`; this is them, gated on the hit.
|
||||||
|
|
||||||
|
### The leftover that matters
|
||||||
|
|
||||||
|
Here's the seed for step 10: if you hit at `t = 0.3`, you only used **30%** of
|
||||||
|
this frame. The other **70%** is still owed to the player — they should keep
|
||||||
|
moving for the rest of the frame, just not *into* the wall. That leftover time is
|
||||||
|
exactly why sliding (step 09) and the loop (step 10) exist. A collision doesn't
|
||||||
|
end the frame; it **interrupts** it.
|
||||||
|
|
||||||
|
> Real-engine footnote: production code usually moves to `t - EPSILON` (a hair
|
||||||
|
> *short* of contact) so floating-point error can't leave the box a sliver inside
|
||||||
|
> the wall, where the next frame's sweep would start already-overlapping. Your
|
||||||
|
> `nage` does this with `Math.max(0, time - EPSILON)`. We keep the kata exact so
|
||||||
|
> the numbers stay clean — just know that tiny backoff is there for a real reason.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `resolve(pos, v, hit)` in `resolve.ts`: return the full move when `hit`
|
||||||
|
is `null`, otherwise the position at contact. `vec/add/scale` and the `Hit` type
|
||||||
|
are in `given.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/08-resolve
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Finished in earlier steps.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
|
||||||
|
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
|
||||||
|
|
||||||
|
/** From step 06/07. */
|
||||||
|
export type Hit = { time: number; normal: Vec };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { vec } from "./given.ts";
|
||||||
|
import { resolve } from "./resolve.ts";
|
||||||
|
|
||||||
|
test("no collision -> full move", () => {
|
||||||
|
expect(resolve(vec(0, 0), vec(10, 5), null)).toEqual({ x: 10, y: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collision at t=0.3 -> stop at contact", () => {
|
||||||
|
const hit = { time: 0.3, normal: vec(-1, 0) };
|
||||||
|
const p = resolve(vec(0, 0), vec(10, 0), hit);
|
||||||
|
expect(p.x).toBeCloseTo(3);
|
||||||
|
expect(p.y).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collision at t=0 -> don't move at all", () => {
|
||||||
|
const hit = { time: 0, normal: vec(-1, 0) };
|
||||||
|
expect(resolve(vec(5, 5), vec(10, 10), hit)).toEqual({ x: 5, y: 5 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { add, type Hit, scale, type Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new position after this frame's move, stopping at a collision if there is
|
||||||
|
* one.
|
||||||
|
* hit === null -> pos + v (take the whole move)
|
||||||
|
* hit -> pos + v * time (stop at the contact point)
|
||||||
|
*/
|
||||||
|
export function resolve(pos: Vec, v: Vec, hit: Hit | null): Vec {
|
||||||
|
return hit ? add(pos, scale(v, hit.time)) : add(pos, v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Step 09 — Slide (this is what `dot` was for)
|
||||||
|
|
||||||
|
Back in step 02 you implemented `dot` and I said "you'll see later." This is
|
||||||
|
later. Sliding is the difference between a game that feels good and one where you
|
||||||
|
stick to every wall like glue.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
You're moving with velocity `v` and you hit a wall whose outward normal is `n`.
|
||||||
|
If you just *stop* (velocity → 0), the player jams against the wall — press
|
||||||
|
into a wall diagonally and all motion dies, even the part that was parallel to
|
||||||
|
the wall and perfectly fine. What we actually want: **cancel only the part of `v`
|
||||||
|
that pushes *into* the wall, and keep the part that runs *along* it.** That's a
|
||||||
|
slide.
|
||||||
|
|
||||||
|
## The math (projection)
|
||||||
|
|
||||||
|
Any velocity `v` can be split into two pieces relative to the wall:
|
||||||
|
|
||||||
|
- the part **along the normal** (into/out of the wall) — this is what the wall forbids,
|
||||||
|
- the part **along the wall surface** (perpendicular to the normal) — this is fine.
|
||||||
|
|
||||||
|
Because `n` is a **unit vector**, the amount of `v` pointing along `n` is exactly
|
||||||
|
`dot(v, n)`. That single number is "how much of `v` goes straight into the wall."
|
||||||
|
The vector piece pointing into the wall is `n * dot(v, n)`. Subtract it off:
|
||||||
|
|
||||||
|
```
|
||||||
|
vSlide = v - n * dot(v, n)
|
||||||
|
```
|
||||||
|
|
||||||
|
What's left has **zero** component along the normal — it lies flat against the
|
||||||
|
wall. (That's the geometric meaning of `dot`: it measures how much two vectors
|
||||||
|
share a direction. Subtract the shared-with-the-normal part, and nothing pointing
|
||||||
|
into the wall survives.)
|
||||||
|
|
||||||
|
### Feel it with numbers
|
||||||
|
|
||||||
|
Move `v = (10, 5)` into a wall whose normal is `n = (-1, 0)` (a vertical wall on
|
||||||
|
your right, pushing you left):
|
||||||
|
|
||||||
|
- `dot(v, n) = 10*(-1) + 5*0 = -10`
|
||||||
|
- `n * dot = (-1,0) * -10 = (10, 0)`
|
||||||
|
- `vSlide = (10,5) - (10,0) = (0, 5)`
|
||||||
|
|
||||||
|
The rightward motion (10) is gone — that was driving you into the wall — but the
|
||||||
|
downward motion (5) survives untouched. You slide down the wall. Exactly right.
|
||||||
|
|
||||||
|
This is the very last line of your engine's `moveAndSlide`:
|
||||||
|
`velocity -= normal * (velocity · normal)`.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `slide(v, normal)` in `slide.ts` using `dot`, `scale`, and `sub` (all
|
||||||
|
in `given.ts`). Return the velocity with its into-the-wall component removed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/09-slide
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Finished in earlier steps.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
|
||||||
|
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
|
||||||
|
export const dot = (a: Vec, b: Vec): number => a.x * b.x + a.y * b.y;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { dot, vec } from "./given.ts";
|
||||||
|
import { slide } from "./slide.ts";
|
||||||
|
|
||||||
|
test("vertical wall kills horizontal motion, keeps vertical", () => {
|
||||||
|
// moving (10,5) into a wall with normal (-1,0)
|
||||||
|
const s = slide(vec(10, 5), vec(-1, 0));
|
||||||
|
expect(s.x).toBeCloseTo(0);
|
||||||
|
expect(s.y).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("horizontal wall kills vertical motion, keeps horizontal", () => {
|
||||||
|
const s = slide(vec(10, 5), vec(0, -1));
|
||||||
|
expect(s.x).toBeCloseTo(10);
|
||||||
|
expect(s.y).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("result always runs along the wall (perpendicular to the normal)", () => {
|
||||||
|
// a diagonal (unit) normal: 0.36 + 0.64 = 1
|
||||||
|
const normal = vec(-0.6, -0.8);
|
||||||
|
const s = slide(vec(10, 0), normal);
|
||||||
|
// the slid velocity has no component into the wall -> dot with normal is 0
|
||||||
|
expect(dot(s, normal)).toBeCloseTo(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { dot, scale, sub, type Vec } from "./given.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the component of `v` that points into a wall with the given (unit)
|
||||||
|
* normal, leaving only the motion that runs along the wall.
|
||||||
|
*
|
||||||
|
* vSlide = v - normal * dot(v, normal)
|
||||||
|
*/
|
||||||
|
export function slide(v: Vec, normal: Vec): Vec {
|
||||||
|
return sub(v, scale(normal, dot(v, normal)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Step 10 — moveAndSlide (the whole engine, in one loop)
|
||||||
|
|
||||||
|
This is it. Every function you've written since step 01 gets tied together here
|
||||||
|
into the exact loop your real `nage` engine runs. It's the hardest step, so I'll
|
||||||
|
give you the skeleton — you write the code.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
A single collision doesn't end the frame (step 08's "leftover that matters"). You
|
||||||
|
hit a wall at `t = 0.3`, slide, and **70% of the frame is still owed** — during
|
||||||
|
which you might hit *another* wall, slide again, and so on. So moving is a small
|
||||||
|
**loop**: sweep → stop at the nearest hit → slide → repeat with the leftover time.
|
||||||
|
|
||||||
|
We loop a **maximum of 4 times** (your engine's cap) — enough to handle a corner
|
||||||
|
(hit a wall, slide, hit the perpendicular wall, slide, stop) without ever risking
|
||||||
|
an infinite loop.
|
||||||
|
|
||||||
|
## The algorithm
|
||||||
|
|
||||||
|
You're given the moving `box`, its full-frame displacement `v`, and a list of
|
||||||
|
static `walls`. Track a running `pos`, a running `vel`, and `timeLeft` (fraction
|
||||||
|
of the frame remaining, starts at `1`).
|
||||||
|
|
||||||
|
```
|
||||||
|
pos = { box.x, box.y }
|
||||||
|
vel = { v.x, v.y }
|
||||||
|
timeLeft = 1
|
||||||
|
|
||||||
|
repeat up to 4 times, while timeLeft > 0:
|
||||||
|
move = vel * timeLeft // what's left to travel this frame
|
||||||
|
|
||||||
|
find the NEAREST hit: for each wall, sweptAABB(box-at-pos, move, wall);
|
||||||
|
keep the hit with the smallest .time
|
||||||
|
|
||||||
|
if no hit:
|
||||||
|
pos = pos + move // clear path: take the rest of the move
|
||||||
|
stop
|
||||||
|
|
||||||
|
else:
|
||||||
|
pos = pos + move * max(0, hit.time - EPSILON) // advance to just before contact
|
||||||
|
vel = slide(vel, hit.normal) // redirect along the wall
|
||||||
|
timeLeft = timeLeft * (1 - hit.time) // consume the used fraction
|
||||||
|
|
||||||
|
return pos
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things worth understanding, not just copying:
|
||||||
|
|
||||||
|
- **`move = vel * timeLeft`.** `vel` is a *full-frame* displacement (how far you'd
|
||||||
|
go in a whole frame at this velocity). You only have `timeLeft` of the frame
|
||||||
|
left, so the actual travel is `vel * timeLeft`. `sweptAABB`'s returned `time` is
|
||||||
|
then a fraction *of that sub-move*, which is why `pos + move * time` is correct.
|
||||||
|
|
||||||
|
- **The `EPSILON` backoff** (`max(0, hit.time - EPSILON)`). Stop a hair *short* of
|
||||||
|
the wall. If you land exactly on it, floating-point error can leave you a sliver
|
||||||
|
inside — and next iteration's sweep would start already-overlapping, reporting a
|
||||||
|
garbage negative-time "collision" that makes you stick or jitter. That tiny gap
|
||||||
|
is exactly the `Math.max(0, time - EPSILON)` in your real `moveAndSlide`. Now you
|
||||||
|
know *why* it's there. `EPSILON` is provided in `given.ts`.
|
||||||
|
|
||||||
|
> **Moving-vs-moving (why your real engine has `velocity - otherVel`).** Here the
|
||||||
|
> walls are static, so we sweep with plain `vel`. When the *other* body also moves,
|
||||||
|
> you sweep in its frame of reference by using the **relative** velocity
|
||||||
|
> `vel - otherVel` — then the exact same loop works, because from the other body's
|
||||||
|
> point of view it's standing still. That's the only difference between this kata
|
||||||
|
> and the full engine. The loop itself doesn't change.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement `moveAndSlide(box, v, walls)` in `moveAndSlide.ts`. Everything you need —
|
||||||
|
`sweptAABB`, `slide`, the vector ops, `EPSILON` — is finished in `given.ts`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun test workshop/steps/10-move-and-slide
|
||||||
|
```
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// The whole kernel you've built, steps 01-09, finished. Reuse it.
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
export type AABB = { x: number; y: number; w: number; h: number };
|
||||||
|
export type Span = { entry: number; exit: number };
|
||||||
|
export type Hit = { time: number; normal: Vec };
|
||||||
|
|
||||||
|
/** A hair of slack so we stop just short of a wall instead of inside it. */
|
||||||
|
export const EPSILON = 1e-4;
|
||||||
|
|
||||||
|
export const vec = (x: number, y: number): Vec => ({ x, y });
|
||||||
|
export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
|
||||||
|
export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
|
||||||
|
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
|
||||||
|
export const dot = (a: Vec, b: Vec): number => a.x * b.x + a.y * b.y;
|
||||||
|
|
||||||
|
export function sweepInterval(
|
||||||
|
p: number,
|
||||||
|
v: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): Span | null {
|
||||||
|
if (v === 0) {
|
||||||
|
return min <= p && p <= max
|
||||||
|
? { entry: -Infinity, exit: Infinity }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
let t1 = (min - p) / v;
|
||||||
|
let t2 = (max - p) / v;
|
||||||
|
if (t1 > t2) {
|
||||||
|
[t1, t2] = [t2, t1];
|
||||||
|
}
|
||||||
|
return { entry: t1, exit: t2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
|
||||||
|
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||||||
|
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||||||
|
if (spanX === null || spanY === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = Math.max(spanX.entry, spanY.entry);
|
||||||
|
const exit = Math.min(spanX.exit, spanY.exit);
|
||||||
|
if (entry > exit || entry >= 1 || exit <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normal =
|
||||||
|
spanX.entry > spanY.entry
|
||||||
|
? { x: v.x > 0 ? -1 : 1, y: 0 }
|
||||||
|
: { x: 0, y: v.y > 0 ? -1 : 1 };
|
||||||
|
return { time: entry, normal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweptAABB(a: AABB, v: Vec, b: AABB): Hit | null {
|
||||||
|
const inflated: AABB = {
|
||||||
|
x: b.x - a.w,
|
||||||
|
y: b.y - a.h,
|
||||||
|
w: b.w + a.w,
|
||||||
|
h: b.h + a.h,
|
||||||
|
};
|
||||||
|
return rayVsAABB({ x: a.x, y: a.y }, v, inflated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slide(v: Vec, normal: Vec): Vec {
|
||||||
|
return sub(v, scale(normal, dot(v, normal)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { type AABB, vec } from "./given.ts";
|
||||||
|
import { moveAndSlide } from "./moveAndSlide.ts";
|
||||||
|
|
||||||
|
const player: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||||
|
|
||||||
|
test("no walls -> full move", () => {
|
||||||
|
const p = moveAndSlide(player, vec(10, 5), []);
|
||||||
|
expect(p.x).toBeCloseTo(10);
|
||||||
|
expect(p.y).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("head-on into the NEAREST of two walls -> stops there", () => {
|
||||||
|
const walls: AABB[] = [
|
||||||
|
{ x: 5, y: 0, w: 2, h: 2 }, // nearer
|
||||||
|
{ x: 8, y: 0, w: 2, h: 2 }, // farther
|
||||||
|
];
|
||||||
|
const p = moveAndSlide(player, vec(10, 0), walls);
|
||||||
|
expect(p.x).toBeCloseTo(3); // right edge (2) meets wall left edge (5)
|
||||||
|
expect(p.y).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagonal into a vertical wall -> slides down it", () => {
|
||||||
|
// x is blocked at 3, but the downward motion keeps going for the whole frame
|
||||||
|
const walls: AABB[] = [{ x: 5, y: -10, w: 2, h: 40 }];
|
||||||
|
const p = moveAndSlide(player, vec(10, 10), walls);
|
||||||
|
expect(p.x).toBeCloseTo(3);
|
||||||
|
expect(p.y).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagonal onto a floor -> slides along it", () => {
|
||||||
|
// falls until its bottom meets the floor top (y=8 -> corner y=6), keeps moving in x
|
||||||
|
const walls: AABB[] = [{ x: 0, y: 8, w: 20, h: 2 }];
|
||||||
|
const p = moveAndSlide(player, vec(5, 10), walls);
|
||||||
|
expect(p.x).toBeCloseTo(5);
|
||||||
|
expect(p.y).toBeCloseTo(6);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
type AABB,
|
||||||
|
add,
|
||||||
|
EPSILON,
|
||||||
|
type Hit,
|
||||||
|
scale,
|
||||||
|
slide,
|
||||||
|
sweptAABB,
|
||||||
|
type Vec,
|
||||||
|
} from "./given.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move `box` by the full-frame displacement `v`, sliding along any `walls` it
|
||||||
|
* hits, up to 4 iterations. Returns the final corner position.
|
||||||
|
*
|
||||||
|
* See the README for the algorithm. You already have everything you need in
|
||||||
|
* given.ts: sweptAABB, slide, add, scale, EPSILON.
|
||||||
|
*/
|
||||||
|
export function moveAndSlide(box: AABB, v: Vec, walls: AABB[]): Vec {
|
||||||
|
let pos: Vec = { x: box.x, y: box.y };
|
||||||
|
let vel: Vec = { x: v.x, y: v.y };
|
||||||
|
|
||||||
|
let timeLeft = 1;
|
||||||
|
let i = 0;
|
||||||
|
while (i++ < 4 && timeLeft > 0) {
|
||||||
|
const move = scale(vel, timeLeft);
|
||||||
|
let nearest: Hit = {
|
||||||
|
normal: { x: 0, y: 0 },
|
||||||
|
time: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const wall of walls) {
|
||||||
|
const hit = sweptAABB(
|
||||||
|
{
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
w: box.w,
|
||||||
|
h: box.h,
|
||||||
|
},
|
||||||
|
move,
|
||||||
|
wall,
|
||||||
|
);
|
||||||
|
if (hit !== null) {
|
||||||
|
nearest = hit.time < nearest.time ? hit : nearest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearest.time === 2) {
|
||||||
|
pos = add(pos, move);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON)));
|
||||||
|
vel = slide(vel, nearest.normal);
|
||||||
|
timeLeft = timeLeft * (1 - nearest.time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Step 11 — Capstone: watch your kernel run
|
||||||
|
|
||||||
|
No test to make green here. This is the payoff. `game.js` contains the **exact
|
||||||
|
engine you built** across steps 01–10 — the same `sweptAABB`, `slide`, and
|
||||||
|
`moveAndSlide`, assembled into one file — wired to a canvas and the arrow keys.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
It's a plain HTML file with no build step. Any of these work:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# simplest: just open the file in a browser
|
||||||
|
xdg-open workshop/steps/11-capstone/index.html # linux
|
||||||
|
# or drag index.html into a browser window
|
||||||
|
|
||||||
|
# or serve it (nicer, avoids any file:// quirks):
|
||||||
|
bunx serve workshop/steps/11-capstone
|
||||||
|
# then open the printed http://localhost:... URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Arrow keys move the pink box. Run it into the border, the ledge, the pillar, the
|
||||||
|
bar. Push diagonally into a wall and watch it **slide** along instead of sticking.
|
||||||
|
That sliding is your step-09 `dot`-product projection. The fact that it stops
|
||||||
|
*at* the wall instead of tunneling through, even at speed, is your step-07 swept
|
||||||
|
detection. The clean corners are your step-10 loop running twice in one frame.
|
||||||
|
|
||||||
|
## Make it yours (optional)
|
||||||
|
|
||||||
|
- Open `game.js`. The top half is your kernel — read it and confirm it matches
|
||||||
|
what you wrote. Swap in your own `moveAndSlide` from step 10 and check it feels
|
||||||
|
identical (it will).
|
||||||
|
- Add a wall to the `walls` array. Change `SPEED`. Make the player bigger.
|
||||||
|
- Try **deleting the `EPSILON` backoff** (`Math.max(0, nearest.time - EPSILON)`
|
||||||
|
→ `nearest.time`) and push into a wall. Watch it stick and jitter. Then put it
|
||||||
|
back. Now you've *felt* why that line exists in your real engine.
|
||||||
|
|
||||||
|
## You're back
|
||||||
|
|
||||||
|
That's the whole climb: pairs of numbers → sweeping a point → sweeping a box via
|
||||||
|
Minkowski → detecting the hit → stopping and sliding → the full loop → a thing you
|
||||||
|
can play. Every rung is a function that exists, by name, inside your real
|
||||||
|
`engine/system/physics.ts`.
|
||||||
|
|
||||||
|
Now go open the real `sweptAABB` with fresh eyes. You know exactly what every line
|
||||||
|
is *supposed* to do — so the two lines that don't should stand out. Happy hunting.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// ── The physics kernel you built across steps 01–10, assembled in one file ──
|
||||||
|
// (Plain JS so the demo runs from a file:// URL with zero build step. Compare it
|
||||||
|
// to your own step-10 code — it's the same engine you wrote.)
|
||||||
|
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
|
||||||
|
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
|
||||||
|
const scale = (a, s) => ({ x: a.x * s, y: a.y * s });
|
||||||
|
const dot = (a, b) => a.x * b.x + a.y * b.y;
|
||||||
|
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y);
|
||||||
|
function normalize(a) {
|
||||||
|
const l = length(a);
|
||||||
|
return l === 0 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPSILON = 1e-4;
|
||||||
|
|
||||||
|
function sweepInterval(p, v, min, max) {
|
||||||
|
if (v === 0) {
|
||||||
|
return min <= p && p <= max ? { entry: -Infinity, exit: Infinity } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t1 = (min - p) / v;
|
||||||
|
let t2 = (max - p) / v;
|
||||||
|
if (t1 > t2) {
|
||||||
|
[t1, t2] = [t2, t1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entry: t1, exit: t2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rayVsAABB(p, v, box) {
|
||||||
|
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||||||
|
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||||||
|
if (spanX === null || spanY === null) return null;
|
||||||
|
const entry = Math.max(spanX.entry, spanY.entry);
|
||||||
|
const exit = Math.min(spanX.exit, spanY.exit);
|
||||||
|
if (entry > exit || entry >= 1 || exit <= 0) return null;
|
||||||
|
const normal =
|
||||||
|
spanX.entry > spanY.entry
|
||||||
|
? { x: v.x > 0 ? -1 : 1, y: 0 }
|
||||||
|
: { x: 0, y: v.y > 0 ? -1 : 1 };
|
||||||
|
return { time: entry, normal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweptAABB(a, v, b) {
|
||||||
|
const inflated = { x: b.x - a.w, y: b.y - a.h, w: b.w + a.w, h: b.h + a.h };
|
||||||
|
return rayVsAABB({ x: a.x, y: a.y }, v, inflated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slide = (v, n) => sub(v, scale(n, dot(v, n)));
|
||||||
|
|
||||||
|
function moveAndSlide(box, v, walls) {
|
||||||
|
let pos = { x: box.x, y: box.y };
|
||||||
|
let vel = { x: v.x, y: v.y };
|
||||||
|
let timeLeft = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4 && timeLeft > 0; i++) {
|
||||||
|
const move = scale(vel, timeLeft);
|
||||||
|
let nearest = null;
|
||||||
|
for (const wall of walls) {
|
||||||
|
const hit = sweptAABB(
|
||||||
|
{ x: pos.x, y: pos.y, w: box.w, h: box.h },
|
||||||
|
move,
|
||||||
|
wall,
|
||||||
|
);
|
||||||
|
if (hit && (nearest === null || hit.time < nearest.time)) {
|
||||||
|
nearest = hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest === null) {
|
||||||
|
pos = add(pos, move);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON)));
|
||||||
|
vel = slide(vel, nearest.normal);
|
||||||
|
timeLeft *= 1 - nearest.time;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── the playable demo ───────────────────────────
|
||||||
|
|
||||||
|
const W = 240;
|
||||||
|
const H = 160;
|
||||||
|
const SPEED = 0.07; // units per millisecond
|
||||||
|
|
||||||
|
const canvas = document.getElementById("view");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// player is an AABB (top-left corner + size)
|
||||||
|
const player = { x: 40, y: 40, w: 12, h: 12 };
|
||||||
|
|
||||||
|
// walls: a border plus a few interior blocks to slide against
|
||||||
|
const T = 8; // border thickness
|
||||||
|
|
||||||
|
const ledge = { x: 70, y: 40, w: 40, h: 16 }; // a ledge
|
||||||
|
let ledgeDir = 1;
|
||||||
|
|
||||||
|
const walls = [
|
||||||
|
{ x: 0, y: 0, w: W, h: T }, // top
|
||||||
|
{ x: 0, y: H - T, w: W, h: T }, // bottom
|
||||||
|
{ x: 0, y: 0, w: T, h: H }, // left
|
||||||
|
{ x: W - T, y: 0, w: T, h: H }, // right
|
||||||
|
{ x: 150, y: 30, w: 16, h: 90 }, // a pillar
|
||||||
|
{ x: 96, y: 96, w: 60, h: 16 }, // a bar
|
||||||
|
ledge,
|
||||||
|
];
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
ArrowUp: false,
|
||||||
|
ArrowDown: false,
|
||||||
|
ArrowLeft: false,
|
||||||
|
ArrowRight: false,
|
||||||
|
};
|
||||||
|
addEventListener("keydown", (e) => {
|
||||||
|
if (e.key in keys) {
|
||||||
|
keys[e.key] = true;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addEventListener("keyup", (e) => {
|
||||||
|
if (e.key in keys) {
|
||||||
|
keys[e.key] = false;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawBox(b, fill, stroke) {
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
if (stroke) {
|
||||||
|
ctx.strokeStyle = stroke;
|
||||||
|
ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let then = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
const delta = Math.min(now - then, 50); // clamp huge deltas (tab was hidden)
|
||||||
|
then = now;
|
||||||
|
|
||||||
|
// input -> direction -> velocity (units/ms), normalized so diagonals aren't faster
|
||||||
|
const dir = normalize({
|
||||||
|
x: (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0),
|
||||||
|
y: (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ledge.x + ledge.w >= W) {
|
||||||
|
ledgeDir = -1;
|
||||||
|
} else if (ledge.x <= 0) {
|
||||||
|
ledgeDir = 1;
|
||||||
|
}
|
||||||
|
ledge.x += SPEED * ledgeDir * delta;
|
||||||
|
|
||||||
|
const velocity = scale(dir, SPEED);
|
||||||
|
|
||||||
|
// full-frame displacement is velocity * delta — exactly what moveAndSlide expects
|
||||||
|
const displacement = scale(velocity, delta);
|
||||||
|
const next = moveAndSlide(player, displacement, walls);
|
||||||
|
player.x = next.x;
|
||||||
|
player.y = next.y;
|
||||||
|
|
||||||
|
// draw
|
||||||
|
ctx.fillStyle = "#12141c";
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
for (let gx = 0; gx <= W; gx += 16) {
|
||||||
|
ctx.fillStyle = "#191c26";
|
||||||
|
ctx.fillRect(gx, 0, 1, H);
|
||||||
|
}
|
||||||
|
for (let gy = 0; gy <= H; gy += 16) {
|
||||||
|
ctx.fillStyle = "#191c26";
|
||||||
|
ctx.fillRect(0, gy, W, 1);
|
||||||
|
}
|
||||||
|
for (const wall of walls) drawBox(wall, "#2e3550", "#3d4670");
|
||||||
|
drawBox(player, "#ee459e", "#ffa8d6");
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>nage physics kernel — capstone</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #0b0c10;
|
||||||
|
color: #c8cde0;
|
||||||
|
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 720px;
|
||||||
|
max-width: 96vw;
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 1px solid #2e3550;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 10px 40px #0008;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #ee459e;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #7d84a0;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
background: #1b1f2b;
|
||||||
|
border: 1px solid #2e3550;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: #c8cde0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>your swept-AABB kernel, live</h1>
|
||||||
|
<canvas id="view" width="240" height="160"></canvas>
|
||||||
|
<p>
|
||||||
|
<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> to move — run into
|
||||||
|
the walls and feel it slide
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script src="./game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user