mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Animation Timeline Initial (#88)
This PR adds an initial animation timeline. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Danny McGee <dannymcgee@gmail.com>
This commit is contained in:
Generated
+88
-45
@@ -290,7 +290,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
@@ -301,21 +301,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.5.3"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f2db9467baa66a700abce2a18c5ad793f6f83310aca1284796fc3921d113fd"
|
||||
checksum = "17ae5ebefcc48e7452b4987947920dac9450be1110cadf34d1b8c116bdbaf97c"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"async-lock 3.3.0",
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand 2.0.1",
|
||||
"futures-lite 1.13.0",
|
||||
"futures-lite 2.2.0",
|
||||
"slab",
|
||||
]
|
||||
|
||||
@@ -325,7 +325,7 @@ version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"async-lock 2.8.0",
|
||||
"autocfg",
|
||||
"blocking",
|
||||
"futures-lite 1.13.0",
|
||||
@@ -337,7 +337,18 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d034b430882f8381900d3fe6f0aaa3ad94f2cb4ac519b429692a1bc2dda4ae7b"
|
||||
dependencies = [
|
||||
"event-listener 4.0.3",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -498,7 +509,7 @@ checksum = "935984568f75867dd7357133b06f4b1502cd2be55e4642d483ce597e46e63bff"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-fs",
|
||||
"async-lock",
|
||||
"async-lock 2.8.0",
|
||||
"bevy_app",
|
||||
"bevy_asset_macros",
|
||||
"bevy_ecs",
|
||||
@@ -647,7 +658,7 @@ dependencies = [
|
||||
"bevy_tasks",
|
||||
"bevy_utils",
|
||||
"downcast-rs",
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
"fixedbitset",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
@@ -1357,6 +1368,16 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_tweening"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "216b3cb82fd0d149c5a9abfb5e382d8a671ff469f8759116e091c710bfa37139"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"interpolation 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bevy_ui"
|
||||
version = "0.12.1"
|
||||
@@ -1617,7 +1638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c36a4d0d48574b3dd360b4b7d95cc651d2b6557b6402848a27d4b228a473e2a"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-lock",
|
||||
"async-lock 2.8.0",
|
||||
"async-task",
|
||||
"fastrand 2.0.1",
|
||||
"futures-io",
|
||||
@@ -2312,6 +2333,27 @@ version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3"
|
||||
dependencies = [
|
||||
"event-listener 4.0.3",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.72.0"
|
||||
@@ -3062,7 +3104,7 @@ dependencies = [
|
||||
"curl",
|
||||
"curl-sys",
|
||||
"encoding_rs",
|
||||
"event-listener",
|
||||
"event-listener 2.5.3",
|
||||
"futures-lite 1.13.0",
|
||||
"http",
|
||||
"log",
|
||||
@@ -3171,9 +3213,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.65"
|
||||
version = "0.3.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8"
|
||||
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3252,9 +3294,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.148"
|
||||
version = "0.2.153"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
|
||||
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -3846,9 +3888,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
@@ -4394,15 +4436,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.21"
|
||||
version = "0.38.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3"
|
||||
checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
|
||||
dependencies = [
|
||||
"bitflags 2.4.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4495,9 +4537,9 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.192"
|
||||
version = "1.0.197"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001"
|
||||
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@@ -4515,9 +4557,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.192"
|
||||
version = "1.0.197"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1"
|
||||
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4815,6 +4857,7 @@ dependencies = [
|
||||
"bevy_mod_picking",
|
||||
"bevy_mod_raycast",
|
||||
"bevy_rapier3d",
|
||||
"bevy_tweening",
|
||||
"bevy_web_asset",
|
||||
"bitflags 2.4.0",
|
||||
"bytemuck",
|
||||
@@ -4951,18 +4994,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.3.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.49"
|
||||
version = "1.0.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
|
||||
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
@@ -4989,9 +5032,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.49"
|
||||
version = "1.0.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
|
||||
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5342,9 +5385,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.88"
|
||||
version = "0.2.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce"
|
||||
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen-macro",
|
||||
@@ -5352,9 +5395,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.88"
|
||||
version = "0.2.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217"
|
||||
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
@@ -5367,9 +5410,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.37"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03"
|
||||
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
@@ -5379,9 +5422,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.88"
|
||||
version = "0.2.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2"
|
||||
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5389,9 +5432,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.88"
|
||||
version = "0.2.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907"
|
||||
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5402,9 +5445,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.88"
|
||||
version = "0.2.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b"
|
||||
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
@@ -5432,9 +5475,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.65"
|
||||
version = "0.3.67"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85"
|
||||
checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
Generated
+17
@@ -48,6 +48,7 @@
|
||||
"vite-plugin-dts": "~2.3.0",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3",
|
||||
"vitest": "~0.32.0",
|
||||
"which": "^4.0.0"
|
||||
}
|
||||
@@ -10409,6 +10410,22 @@
|
||||
"vite": "^2 || ^3 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-web-components-hmr": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-web-components-hmr/-/vite-plugin-web-components-hmr-0.1.3.tgz",
|
||||
"integrity": "sha512-UF+YYOFyaie6cT7XPatRz2Xo1Fh3TdTwOdCq1Z0EjzMfdAlUiUdF3crkN0DdEhctWBvq18CHxJZUTKw7x1zSRQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.12.3",
|
||||
"@babel/plugin-syntax-class-properties": "^7.12.13",
|
||||
"@babel/plugin-syntax-import-assertions": "^7.12.1",
|
||||
"@babel/plugin-syntax-top-level-await": "^7.12.1",
|
||||
"picomatch": "^2.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "0.32.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.32.4.tgz",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"vite-plugin-dts": "~2.3.0",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3",
|
||||
"vitest": "~0.32.0",
|
||||
"which": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
:host {
|
||||
display: flex;
|
||||
display: grid;
|
||||
border-radius: 8px;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
grid-template-columns:
|
||||
[sidebar-start] min-content [sidebar-end
|
||||
divider-start] 8px [divider-end
|
||||
timeline-start] 1fr [timeline-end];
|
||||
padding: 8px;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar, .timeline { padding: 8px; }
|
||||
.sidebar { padding-inline-end: 0; }
|
||||
.timeline { padding-inline-start: 0; }
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
grid-column: sidebar;
|
||||
grid-row: 1 / span var(--row-count);
|
||||
grid-template-rows: subgrid;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
grid-column: timeline;
|
||||
grid-row: 1 / span var(--row-count);
|
||||
grid-template-rows: subgrid;
|
||||
}
|
||||
|
||||
.btn-keyframe {
|
||||
@@ -28,7 +37,11 @@
|
||||
background: #7A2C34E3;
|
||||
color: #FFF;
|
||||
|
||||
&:hover {
|
||||
&.disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:not(.disabled):hover {
|
||||
background: #90363F;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +71,8 @@
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: divider;
|
||||
grid-row: 1 / span var(--row-count);
|
||||
position: relative;
|
||||
flex: 0 0 8px;
|
||||
margin: {
|
||||
@@ -83,6 +98,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
.tracklist {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-row: 2 / span var(--track-count);
|
||||
|
||||
|
||||
.track {
|
||||
&:hover {
|
||||
background: #0000001F;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #7A2C34E3;
|
||||
color: #FFF;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: #90363F !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { type CameraKeyframesEvent } from "@storyteller/studio";
|
||||
import { bind, drag, on } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit";
|
||||
import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe } from "@storyteller/studio";
|
||||
import { bind, drag, inject, on, provide } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult, PropertyValueMap } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
@@ -11,32 +11,170 @@ import "@storyteller/studio-ui/tree";
|
||||
import "./anim-timeline.element";
|
||||
|
||||
import styles from "./anim-editor.element.scss?inline";
|
||||
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "@storyteller/studio-web/scene-data-provider";
|
||||
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
|
||||
|
||||
const TRACK_ICONS : Record<string, string> = {
|
||||
camera: "video",
|
||||
transform: "up-down-left-right",
|
||||
light: "light"
|
||||
};
|
||||
|
||||
function isOutsideEntity(
|
||||
value?: { track: string } | { outsideEntity: string }
|
||||
): value is { outsideEntity: string } {
|
||||
if (value == null) return false;
|
||||
return "outsideEntity" in value;
|
||||
}
|
||||
|
||||
@customElement("sts-anim-editor")
|
||||
export class AnimEditorElement extends LitElement {
|
||||
@provide(TREE_SELECTION_PROVIDER)
|
||||
export class AnimEditorElement extends LitElement
|
||||
implements TreeSelectionProvider {
|
||||
@inject(SCENE_DATA_PROVIDER)
|
||||
sceneData?: SceneDataProvider;
|
||||
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@state() sidebarWidth = 256;
|
||||
|
||||
@state() timelineStart = 0;
|
||||
@state() timelineEnd = 5_000;
|
||||
@state() timelineEnd = 5;
|
||||
|
||||
@state() clipStart = 0;
|
||||
@state() clipEnd = 15_000;
|
||||
@state() clipEnd = 0;
|
||||
|
||||
@state() playHead = 0;
|
||||
@state() keyframes: Record<string, number> = {};
|
||||
@state() playing = false;
|
||||
|
||||
@state() tracks : Record<string, {name?: string, target_type: studio.SceneElement }> = {};
|
||||
|
||||
@state() track_ordered : string[] = [];
|
||||
|
||||
@state() keyframes : Record<string, Keyframe> = {};
|
||||
|
||||
@state() selectedTrack?: { track: string } | { outsideEntity: string };
|
||||
|
||||
trackCount(): number {
|
||||
return Object.keys(this.tracks).length;
|
||||
}
|
||||
|
||||
get selectedId() {
|
||||
return !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || null;
|
||||
}
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
if (changes.has("playHead"))
|
||||
studio.seekTime(this.playHead);
|
||||
|
||||
if (changes.has("selectedTrack")) {
|
||||
const treeSelectionChanges: PropertyValues<TreeSelectionProvider> = new Map();
|
||||
const prevSelectedTrack = changes.get("selectedTrack");
|
||||
const prevTreeSelection = !isOutsideEntity(prevSelectedTrack) && prevSelectedTrack?.track || null;
|
||||
treeSelectionChanges.set("selectedId", prevTreeSelection);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("property-changes", {
|
||||
detail: treeSelectionChanges,
|
||||
}));
|
||||
}
|
||||
|
||||
super.update(changes);
|
||||
}
|
||||
|
||||
@on("window:camera-keyframes")
|
||||
onCameraKeyframeChanges({ detail: keyframes }: CameraKeyframesEvent): void {
|
||||
@on("window:seek-time-changed")
|
||||
onSeekTimeChanged({ detail: {time, playing} }: SeekTimeChanged ): void {
|
||||
console.log("Seek Time Changed", time, playing);
|
||||
this.playHead = time;
|
||||
this.playing = playing;
|
||||
|
||||
if (this.playHead < this.timelineStart) {
|
||||
this.timelineStart = this.playHead - (this.timelineEnd - this.timelineStart) * 0.05;
|
||||
}
|
||||
if (this.playHead > this.timelineEnd) {
|
||||
this.timelineEnd = this.playHead + (this.timelineEnd - this.timelineStart) * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
@on("window:update-animation-targets")
|
||||
onUpdateAnimationTargets({detail: { added, removed}}: UpdateAnimationTargets) : void {
|
||||
let addedKeys = Object.keys(added);
|
||||
if (addedKeys.length === 0 && removed.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log("Animation Targets Changed");
|
||||
let tracks = {...this.tracks};
|
||||
for (let track_entity of addedKeys) {
|
||||
let track = added[track_entity];
|
||||
tracks[track_entity] = track;
|
||||
}
|
||||
|
||||
for (let track_entity of removed) {
|
||||
delete tracks[track_entity];
|
||||
}
|
||||
|
||||
this.track_ordered = Object.keys(tracks);
|
||||
|
||||
if (typeof this.selectedTrack === "object") {
|
||||
if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) {
|
||||
this.selectedTrack = { outsideEntity: this.selectedTrack.track};
|
||||
} else if (isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity in tracks) {
|
||||
this.selectedTrack = { track: this.selectedTrack.outsideEntity};
|
||||
}
|
||||
}
|
||||
|
||||
this.tracks = tracks;
|
||||
}
|
||||
|
||||
@on("window:update-keyframes")
|
||||
onUpdateKeyframes({detail: {added, removed}}: studio.UpdateKeyframes): void {
|
||||
let addedKeyframes = Object.keys(added);
|
||||
if (addedKeyframes.length === 0 && removed.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log("Keyframes Changed");
|
||||
let keyframes = {...this.keyframes};
|
||||
for (let keyframe of addedKeyframes) {
|
||||
keyframes[keyframe] = added[keyframe];
|
||||
}
|
||||
|
||||
for (let keyframe of removed) {
|
||||
delete keyframes[keyframe];
|
||||
}
|
||||
|
||||
this.keyframes = keyframes;
|
||||
|
||||
let min = Infinity
|
||||
let max = -Infinity;
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
let time = this.keyframes[keyframe].time;
|
||||
if (typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time > max) {
|
||||
max = time;
|
||||
}
|
||||
if (time < min) {
|
||||
min = time;
|
||||
}
|
||||
}
|
||||
this.clipStart = Math.max(min, 0.);
|
||||
this.clipEnd = Math.max(max, min);
|
||||
}
|
||||
|
||||
|
||||
@on("window:entity-select")
|
||||
onEntitySelected({ detail: id }: EntitySelectEvent): void {
|
||||
if (id) {
|
||||
if (this.tracks[id]) {
|
||||
this.selectedTrack = { track: id };
|
||||
} else if (!this.keyframes[id]) {
|
||||
this.selectedTrack = {outsideEntity: id};
|
||||
} else {
|
||||
this.selectedTrack = undefined;
|
||||
}
|
||||
} else {
|
||||
this.selectedTrack = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onSidebarResize(event: PointerEvent): void {
|
||||
@@ -44,47 +182,140 @@ export class AnimEditorElement extends LitElement {
|
||||
}
|
||||
|
||||
addKeyframe(): void {
|
||||
studio.addCameraKeyframe(this.playHead);
|
||||
if (typeof this.selectedTrack === "object" && "track" in this.selectedTrack) {
|
||||
studio.recordKeyframe([this.selectedTrack.track]);
|
||||
}
|
||||
}
|
||||
|
||||
jumpToStart(): void {
|
||||
this.playHead = this.clipStart;
|
||||
if (Math.abs(this.playHead - this.clipStart) < 0.0001) {
|
||||
this.playHead = 0;
|
||||
} else {
|
||||
this.playHead = this.clipStart;
|
||||
}
|
||||
}
|
||||
|
||||
prevKeyframe(): void {
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => b - a);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe < this.playHead)
|
||||
break;
|
||||
let closest_keyframe = -1 * Infinity;
|
||||
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
const time = this.keyframes[keyframe]?.time;
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time < pos && time > closest_keyframe ) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
if (closest_keyframe < 0) {
|
||||
closest_keyframe = 0;
|
||||
}
|
||||
|
||||
this.playHead = closest_keyframe;
|
||||
}
|
||||
|
||||
nextKeyframe(): void {
|
||||
|
||||
let pos = this.playHead;
|
||||
|
||||
const keyframes = Array.from(Object.values(this.keyframes)).sort((a, b) => a - b);
|
||||
for (let keyframe of keyframes) {
|
||||
pos = keyframe;
|
||||
if (keyframe > this.playHead)
|
||||
break;
|
||||
let closest_keyframe = Infinity;
|
||||
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
const time = this.keyframes[keyframe]?.time;
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time > pos && time < closest_keyframe ) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
}
|
||||
|
||||
this.playHead = pos;
|
||||
if (closest_keyframe > this.clipEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playHead = closest_keyframe;
|
||||
}
|
||||
|
||||
jumpToEnd(): void {
|
||||
this.playHead = this.clipEnd;
|
||||
}
|
||||
|
||||
protected override render = (): TemplateResult => html`
|
||||
addTarget(): void {
|
||||
if (isOutsideEntity(this.selectedTrack)) {
|
||||
studio.addEntityTrack(this.selectedTrack.outsideEntity);
|
||||
}
|
||||
}
|
||||
|
||||
togglePlaying(): void {
|
||||
console.log("toggling play");
|
||||
if (this.playing) {
|
||||
studio.pause();
|
||||
} else {
|
||||
studio.play();
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
|
||||
const track_order_hashed : Record<string, number> = {};
|
||||
|
||||
for (const [index, track] of this.track_ordered.entries()) {
|
||||
track_order_hashed[track] = index;
|
||||
}
|
||||
|
||||
const selected = !isOutsideEntity(this.selectedTrack) && this.selectedTrack?.track || false;
|
||||
const canAddSelected = isOutsideEntity(this.selectedTrack) && this.selectedTrack.outsideEntity || false;
|
||||
|
||||
let length = this.timelineEnd - this.timelineStart;
|
||||
let clip_start = (this.clipStart - this.timelineStart) / length;
|
||||
let clip_end = (this.clipEnd - this.timelineStart) / length;
|
||||
|
||||
|
||||
let timeline_css_variables = {
|
||||
'--track-count': `${this.trackCount()}`,
|
||||
'--row-count': `calc(var(--track-count) + ${canAddSelected ? 3 : 2})`,
|
||||
'--clip-end': `${clip_end * 100}%`,
|
||||
'--clip-start': `${clip_start * 100}%`
|
||||
};
|
||||
|
||||
const tracklist = this.track_ordered.map((entity) => {
|
||||
let track = this.tracks[entity];
|
||||
let name = track.name ?? track.target_type;
|
||||
let icon = this.sceneData?.entityMap.get(entity)?.icon ?? "minus";
|
||||
return html`
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name=${name}
|
||||
icon=${icon}
|
||||
@tree-select=${() => {
|
||||
studio.select(entity);
|
||||
}}
|
||||
.id=${entity}
|
||||
></sts-tree>
|
||||
`;
|
||||
});
|
||||
|
||||
if (canAddSelected) {
|
||||
tracklist.push(html`
|
||||
<sts-tree class=${`track`}
|
||||
role="button"
|
||||
.name=${`Add Track: ${this.sceneData?.selectedObject?.name ?? canAddSelected}`}
|
||||
icon="plus-large"
|
||||
@click=${this.addTarget}></sts-tree>
|
||||
`);
|
||||
}
|
||||
|
||||
const none_selected = !selected;
|
||||
|
||||
return html`
|
||||
<div class="sidebar"
|
||||
style=${styleMap({
|
||||
width: `${this.sidebarWidth}px`,
|
||||
...timeline_css_variables
|
||||
})}
|
||||
>
|
||||
<sts-toolbar>
|
||||
@@ -93,6 +324,7 @@ export class AnimEditorElement extends LitElement {
|
||||
icon="key"
|
||||
aria-label="Add Keyframe"
|
||||
title="Add Keyframe"
|
||||
?disabled=${none_selected}
|
||||
@click=${this.addKeyframe}
|
||||
></sts-button>
|
||||
</sts-toolbar-group>
|
||||
@@ -110,9 +342,10 @@ export class AnimEditorElement extends LitElement {
|
||||
@click=${this.prevKeyframe}
|
||||
></sts-button>
|
||||
<sts-button class="play"
|
||||
icon="play"
|
||||
icon=${this.playing ? "pause" : "play" }
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
@click=${this.togglePlaying}
|
||||
></sts-button>
|
||||
<sts-button
|
||||
icon="fwd-step"
|
||||
@@ -129,32 +362,27 @@ export class AnimEditorElement extends LitElement {
|
||||
</sts-toolbar-group>
|
||||
</sts-toolbar>
|
||||
<div class="tracklist" role="list">
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera"
|
||||
icon="video"
|
||||
></sts-tree>
|
||||
<sts-tree class="track"
|
||||
role="listitem"
|
||||
name="Camera Target"
|
||||
icon="crosshairs"
|
||||
></sts-tree>
|
||||
${tracklist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"
|
||||
<div class="divider" style=${styleMap(timeline_css_variables)}
|
||||
${drag({
|
||||
move: this.onSidebarResize,
|
||||
})}
|
||||
></div>
|
||||
|
||||
<sts-anim-timeline class="timeline"
|
||||
style=${styleMap(timeline_css_variables)}
|
||||
.rangeStart=${bind(this, "timelineStart")}
|
||||
.rangeEnd=${bind(this, "timelineEnd")}
|
||||
.clipStart=${this.clipStart}
|
||||
.clipEnd=${this.clipEnd}
|
||||
.playHead=${bind(this, "playHead")}
|
||||
.keyframes=${bind(this, "keyframes")}
|
||||
.track_ordered=${track_order_hashed}
|
||||
></sts-anim-timeline>
|
||||
`;
|
||||
}
|
||||
`
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
:host {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: 1 / span var(--row-count);
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
@@ -8,19 +11,26 @@
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: subgrid;
|
||||
inset: 0;
|
||||
grid-row: 1 / span var(--row-count);
|
||||
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.2) calc(var(--clip-start) - 1px), rgba(255,255,255,0.1) var(--clip-start), rgba(255,255,255,0.1) var(--clip-end), rgba(0,0,0,0.2) calc(var(--clip-end) + 1px));
|
||||
|
||||
&.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-region {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #0004;
|
||||
.zoom-controls {
|
||||
grid-row: var(--row-count);
|
||||
background: rgba(0,0,0,1);
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hash {
|
||||
@@ -37,7 +47,7 @@
|
||||
|
||||
.timestamp {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
grid-row: 1;
|
||||
color: #FFF6;
|
||||
font: {
|
||||
family: "Fira Sans", sans-serif;
|
||||
@@ -100,8 +110,7 @@
|
||||
|
||||
.keyframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateX(-4.5px) translateY(-4.5px);
|
||||
transform: translateX(-4.5px);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
@@ -22,7 +22,7 @@ import styles from "./anim-timeline.element.scss?inline";
|
||||
// which would look something like:
|
||||
// ```
|
||||
// @memo(["rangeStart", "rangeEnd", "_renderWidth"])
|
||||
// msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
// secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
// ```
|
||||
// But that feels a little obtuse and overengineered. In this particular
|
||||
// case we're probably only saving on the order of nanoseconds, so I'm
|
||||
@@ -54,39 +54,36 @@ class Computed extends Number {
|
||||
export class AnimTimelineElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
/** The time (in ms) at the left edge of the timeline view */
|
||||
/** The time (in seconds) at the left edge of the timeline view */
|
||||
@property({ type: Number }) rangeStart = 0;
|
||||
/** The time (in ms) at the right edge of the timeline view */
|
||||
@property({ type: Number }) rangeEnd = 5_000;
|
||||
|
||||
/** The time (in ms) at the beginning of the active clip range */
|
||||
/** The time (in seconds) at the right edge of the timeline view */
|
||||
@property({ type: Number }) rangeEnd = 5;
|
||||
|
||||
|
||||
/** The time (in seconds) at the start of the clip */
|
||||
@property({ type: Number }) clipStart = 0;
|
||||
/** The time (in ms) at the end of the active clip range */
|
||||
@property({ type: Number }) clipEnd = 15_000;
|
||||
/** The time (in seconds) at the end of the clip */
|
||||
@property({ type: Number }) clipEnd = 5;
|
||||
|
||||
/** The time (in ms) of the current play-head position */
|
||||
/** The time (in seconds) of the current play-head position */
|
||||
@property({ type: Number }) playHead = 0;
|
||||
/** Keyframe positions (in ms) */
|
||||
@property({ attribute: false }) keyframes: Record<string, number> = {};
|
||||
/** Keyframe positions (in seconds) */
|
||||
@property({ attribute: false }) keyframes: Record<string, studio.Keyframe> = {};
|
||||
@property({ attribute: false }) track_ordered: Record<string, number> = {}
|
||||
|
||||
@state() _selectedKeyframe: string | null = null;
|
||||
|
||||
/** The width (in px) of the timeline view */
|
||||
@state() _renderWidth!: number;
|
||||
|
||||
/** The offset from 0ms of the left edge of the timeline view, in px */
|
||||
@state() _offsetX = 0;
|
||||
/** The vertical scroll offset (in px) */
|
||||
@state() _offsetY = 0;
|
||||
|
||||
/**
|
||||
* `true` when the timeline is being mouse-panned. Primarily useful for
|
||||
* setting the cursor style to the grabby hand.
|
||||
*/
|
||||
@state() _panning = false;
|
||||
|
||||
/** How many ms are represented by each px of horizontal space. */
|
||||
msPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
/** How many seconds are represented by each px of horizontal space. */
|
||||
secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
||||
|
||||
#resizeObserver: ResizeObserver;
|
||||
|
||||
@@ -107,12 +104,13 @@ export class AnimTimelineElement extends LitElement {
|
||||
}
|
||||
|
||||
protected override update(changes: PropertyValues<this>): void {
|
||||
console.log("Changes: ", changes);
|
||||
if (
|
||||
changes.has("rangeStart")
|
||||
|| changes.has("rangeEnd")
|
||||
|| changes.has("_renderWidth")
|
||||
) {
|
||||
this.msPerPx.invalidate();
|
||||
this.secondsPerPx.invalidate();
|
||||
}
|
||||
|
||||
super.update(changes);
|
||||
@@ -124,7 +122,7 @@ export class AnimTimelineElement extends LitElement {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.ctrlKey) {
|
||||
this.#zoomTimeline(event.offsetX, event.deltaY);
|
||||
this.#zoomTimeline(event.clientX, event.deltaY);
|
||||
} else if (event.deltaX) {
|
||||
this.#pan(-event.deltaX);
|
||||
} else if (event.shiftKey && event.deltaY) {
|
||||
@@ -140,8 +138,9 @@ export class AnimTimelineElement extends LitElement {
|
||||
}
|
||||
|
||||
if (Array.from(Object.keys(this.keyframes)).includes(id)) {
|
||||
let keyframe = this.keyframes[id];
|
||||
this._selectedKeyframe = id;
|
||||
this.dispatchEvent(new CustomEvent("playHead-change", { detail: this.keyframes[id]! }));
|
||||
studio.seekTime(keyframe.time);
|
||||
} else {
|
||||
this._selectedKeyframe = null;
|
||||
}
|
||||
@@ -153,67 +152,63 @@ export class AnimTimelineElement extends LitElement {
|
||||
|
||||
#zoomTimeline(origin: number, delta: number): void {
|
||||
const currentRange = this.rangeEnd - this.rangeStart;
|
||||
const deltaRange = delta * 4;
|
||||
const elementRect = this.getBoundingClientRect();
|
||||
const focusPoint = (origin - elementRect.x) / elementRect.width;
|
||||
|
||||
// FIXME: When zooming the timeline, the transform origin should be
|
||||
// centered on the mouse cursor. Currently this does not work as
|
||||
// intended, always centering on the 00:00 timestamp instead.
|
||||
const t = ((this._offsetX + origin) * this.msPerPx.get()) / currentRange;
|
||||
console.log(`t: ${t}`);
|
||||
console.log(`deltaStart: ${-deltaRange * t}`);
|
||||
console.log(`deltaEnd: ${deltaRange * (1 - t)}`);
|
||||
const rangeStart = this.rangeStart - deltaRange * t;
|
||||
const rangeEnd = this.rangeEnd + deltaRange * (1 - t);
|
||||
const deltaTimeScale = delta * 0.001 * currentRange;
|
||||
|
||||
const rangeStart = Math.max(this.rangeStart - deltaTimeScale * focusPoint, 0.);
|
||||
const rangeEnd = Math.max(this.rangeEnd + deltaTimeScale * (1 - focusPoint), rangeStart + 0.01);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onDragStart(): void {
|
||||
onDragStart(): void {
|
||||
this._panning = true;
|
||||
}
|
||||
|
||||
#onDragEnd(): void {
|
||||
onDragEnd(): void {
|
||||
this._panning = false;
|
||||
}
|
||||
|
||||
#onDrag(event: PointerEvent): void {
|
||||
onDrag(event: PointerEvent): void {
|
||||
this.#pan(event.movementX);
|
||||
}
|
||||
|
||||
#pan(deltaPx: number): void {
|
||||
this._offsetX += deltaPx;
|
||||
const deltaChange = deltaPx * 0.001;
|
||||
const scaledChange = (this.rangeEnd - this.rangeStart) * deltaChange;
|
||||
|
||||
const msPerPx = this.msPerPx.get();
|
||||
const rangeStart = this.rangeStart - deltaPx * msPerPx;
|
||||
const rangeEnd = this.rangeEnd - deltaPx * msPerPx;
|
||||
let rangeStart = this.rangeStart - scaledChange;
|
||||
let rangeEnd = Math.max(this.rangeEnd - scaledChange, rangeStart + 0.01);
|
||||
|
||||
if (rangeStart < 0) {
|
||||
rangeEnd = this.rangeEnd - this.rangeStart;
|
||||
rangeStart = 0;
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
|
||||
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
|
||||
}
|
||||
|
||||
#onPlayheadDrag(event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.playHead + deltaMs;
|
||||
onPlayheadDrag(event: PointerEvent): void {
|
||||
let elementBounds = this.getBoundingClientRect();
|
||||
let relativeX = event.clientX - elementBounds.left;
|
||||
let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1));
|
||||
const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart;
|
||||
const updated = seconds;
|
||||
|
||||
this.dispatchEvent(new CustomEvent("playHead-change", { detail: updated }));
|
||||
}
|
||||
|
||||
#onKeyframeDrag(id: string, event: PointerEvent): void {
|
||||
const deltaMs = event.movementX * this.msPerPx.get();
|
||||
const updated = this.keyframes[id] + deltaMs;
|
||||
onKeyframeDrag(id: string, event: PointerEvent): void {
|
||||
let elementBounds = this.getBoundingClientRect();
|
||||
let relativeX = event.clientX - elementBounds.left;
|
||||
let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1));
|
||||
const time = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart;
|
||||
|
||||
studio.updateKeyframe(id, updated);
|
||||
|
||||
const updatedKeyframes = Object.fromEntries(
|
||||
Object
|
||||
.entries(this.keyframes)
|
||||
.map(entry => entry[0] === id
|
||||
? [id, updated]
|
||||
: entry
|
||||
)
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("keyframes-change", { detail: updatedKeyframes }));
|
||||
studio.moveKeyframeTime(id, time);
|
||||
}
|
||||
|
||||
#updateRenderWidth(): void {
|
||||
@@ -221,67 +216,76 @@ export class AnimTimelineElement extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time value (in ms) for displaying in the timeline view
|
||||
* Format a time value (in seconds) for displaying in the timeline view
|
||||
*/
|
||||
#timestamp(ms: number) {
|
||||
const sign = ms < 0 ? "-" : "";
|
||||
ms = Math.abs(ms);
|
||||
timestamp(seconds: number) {
|
||||
const sign = seconds < 0 ? "-" : "";
|
||||
seconds = Math.abs(seconds);
|
||||
|
||||
let s = Math.floor(ms / 1000);
|
||||
let frac = Math.round(ms % 1000);
|
||||
if (frac === 1000) {
|
||||
frac = 0;
|
||||
s += 1;
|
||||
}
|
||||
|
||||
let m = 0;
|
||||
if (s >= 60) {
|
||||
m = Math.floor(s / 60);
|
||||
s %= 60;
|
||||
}
|
||||
let minutes = Math.floor(seconds / 60);
|
||||
let secondsInMinute = seconds % 60;
|
||||
let flooredSeconds = Math.floor(secondsInMinute);
|
||||
let fraction = Math.floor(100 * (secondsInMinute - flooredSeconds));
|
||||
|
||||
return html`${
|
||||
sign
|
||||
}${
|
||||
m.toString(10).padStart(2, "0")
|
||||
minutes.toString(10).padStart(2, "0")
|
||||
}:${
|
||||
s.toString(10).padStart(2, "0")
|
||||
}<!--<span class="frac">.${
|
||||
frac.toString(10).padEnd(3, "0")
|
||||
}</span>-->`;
|
||||
flooredSeconds.toString(10).padStart(2, "0")
|
||||
}.${
|
||||
fraction.toString(10).padStart(2, "0")
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a time value (in ms) to a px offset from the left edge of the view
|
||||
*/
|
||||
#msToPx(ms: number): number {
|
||||
return this._offsetX + ms / this.msPerPx.get();
|
||||
}
|
||||
|
||||
protected override render = () => {
|
||||
const msPerPx = this.msPerPx.get();
|
||||
protected override render() {
|
||||
const range = this.rangeEnd - this.rangeStart;
|
||||
|
||||
let subdivsPerSec = 32;
|
||||
let subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
let pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
let subdivisionScale = 0.5;
|
||||
let majorSubdivisionScale = 1;
|
||||
|
||||
while (pxPerSubdiv < 64) {
|
||||
subdivsPerSec /= 2;
|
||||
subdivsCount = (range / 1000) * subdivsPerSec;
|
||||
pxPerSubdiv = this._renderWidth / subdivsCount;
|
||||
if (range < 0.5) {
|
||||
subdivisionScale = 0.05;
|
||||
majorSubdivisionScale = 0.1;
|
||||
} else if (range < 2) {
|
||||
subdivisionScale = 1 / 4;
|
||||
majorSubdivisionScale = 0.5;
|
||||
} else if (range > 120) {
|
||||
subdivisionScale = 30;
|
||||
majorSubdivisionScale = 60;
|
||||
} else if (range > 60) {
|
||||
subdivisionScale = 10;
|
||||
majorSubdivisionScale = 30;
|
||||
} else if (range > 10) {
|
||||
subdivisionScale = 5;
|
||||
majorSubdivisionScale = 10;
|
||||
} else if (range > 5) {
|
||||
subdivisionScale = 1;
|
||||
majorSubdivisionScale = 5;
|
||||
}
|
||||
|
||||
const msPerSubdiv = msPerPx * pxPerSubdiv;
|
||||
const subdivs = new Array(Math.ceil(subdivsCount) + 2).fill(null);
|
||||
const scaledStart = this.rangeStart / subdivisionScale;
|
||||
const scaledEnd = this.rangeEnd / subdivisionScale;
|
||||
|
||||
const fracPxOffset = this._offsetX % pxPerSubdiv;
|
||||
console.log("Scaled Start", scaledStart, "Scaled End", scaledEnd, "Range Start", this.rangeStart, "Range End", this.rangeEnd, "Range", range, "Playhead", this.playHead);
|
||||
|
||||
let subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean}> = [];
|
||||
let currentOffset = scaledStart;
|
||||
let currentOffsetSeconds = this.rangeStart;
|
||||
|
||||
let startingIdx = -Math.floor(this._offsetX / pxPerSubdiv);
|
||||
if (fracPxOffset < 0)
|
||||
startingIdx -= 2;
|
||||
else
|
||||
startingIdx -= 1;
|
||||
while (currentOffset < scaledEnd) {
|
||||
const currentSeconds = currentOffsetSeconds;
|
||||
const isMajor = currentOffsetSeconds % majorSubdivisionScale === 0;
|
||||
subdivisions.push({ seconds: currentSeconds, fraction: (currentSeconds - this.rangeStart) / range, isMajor });
|
||||
let nextSubdiv = Math.ceil(currentOffset);
|
||||
if (Math.abs(nextSubdiv - currentOffset) < 0.1) {
|
||||
nextSubdiv += 1;
|
||||
}
|
||||
currentOffset = nextSubdiv;
|
||||
currentOffsetSeconds = currentOffset * subdivisionScale;
|
||||
}
|
||||
|
||||
console.log("Subdivisions", subdivisions.length, subdivisions);
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -291,43 +295,33 @@ export class AnimTimelineElement extends LitElement {
|
||||
})}
|
||||
${drag({
|
||||
button: 1,
|
||||
start: this.#onDragStart,
|
||||
move: this.#onDrag,
|
||||
end: this.#onDragEnd,
|
||||
start: this.onDragStart,
|
||||
move: this.onDrag,
|
||||
end: this.onDragEnd,
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="clip-region"
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.clipStart)}px`,
|
||||
right: `${this._renderWidth - this.#msToPx(this.clipEnd)}px`,
|
||||
})}
|
||||
></div>
|
||||
|
||||
${subdivs.map((_, idx) => {
|
||||
const pxOffset = (idx - 1) * pxPerSubdiv + fracPxOffset;
|
||||
const ms = (startingIdx + idx) * msPerSubdiv;
|
||||
|
||||
const msFrac = Math.abs(Math.round(ms) % 1000);
|
||||
const strong = msFrac === 0;
|
||||
${subdivisions.map(({ seconds, fraction, isMajor}, idx) => {
|
||||
const secondsPercentage = fraction * 100;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
hash: true,
|
||||
strong,
|
||||
strong: isMajor,
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${pxOffset}px`
|
||||
left: `${secondsPercentage}%`
|
||||
})}
|
||||
></div>
|
||||
${strong ? html`
|
||||
${true ? html`
|
||||
<span class="timestamp"
|
||||
style=${styleMap({
|
||||
left: `${pxOffset + 8}px`
|
||||
left: `calc(${secondsPercentage}% + 8px)`
|
||||
})}
|
||||
>
|
||||
${this.#timestamp(ms)}
|
||||
${this.timestamp(seconds)}
|
||||
</span>
|
||||
` : nothing}
|
||||
`;
|
||||
@@ -335,10 +329,10 @@ export class AnimTimelineElement extends LitElement {
|
||||
|
||||
<div class="playhead"
|
||||
${drag({
|
||||
move: this.#onPlayheadDrag
|
||||
move: this.onPlayheadDrag
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(this.playHead)}px`
|
||||
left: `${100 * (this.playHead - this.rangeStart) / range}%`
|
||||
})}
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -349,22 +343,26 @@ export class AnimTimelineElement extends LitElement {
|
||||
${repeat(
|
||||
Object.entries(this.keyframes),
|
||||
([id]) => id,
|
||||
([id, keyframe]) => html`
|
||||
([id, keyframe]) => {
|
||||
let row = this.track_ordered[keyframe.target];
|
||||
return html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
keyframe: true,
|
||||
selected: this._selectedKeyframe === id,
|
||||
selected: this._selectedKeyframe === id
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${this.#msToPx(keyframe)}px`
|
||||
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
|
||||
gridRow: row + 2,
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.#onKeyframeDrag(id, event),
|
||||
move: event => this.onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
></div>
|
||||
`
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -54,8 +54,8 @@ export class AppElement extends LitElement {
|
||||
@state() inspectorWidth = 320;
|
||||
@state() hierarchyHeight?: number;
|
||||
|
||||
#enableAnimTimeline = true; // TODO
|
||||
private api = new StorytellerApi();
|
||||
#enableAnimTimeline = false; // TODO
|
||||
|
||||
#addToSceneButtonRef = createRef<ButtonElement>();
|
||||
#mediaLibraryRef = createRef<DialogElement>();
|
||||
@@ -136,7 +136,8 @@ export class AppElement extends LitElement {
|
||||
this.showHelp = !this.showHelp;
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
protected override render() {
|
||||
return html`
|
||||
<sts-overlay-provider>
|
||||
<sts-canvas-provider class="app-root">
|
||||
<sts-scene-data-provider>
|
||||
@@ -290,4 +291,5 @@ export class AppElement extends LitElement {
|
||||
</sts-canvas-provider>
|
||||
</sts-overlay-provider>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"noImplicitAny": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
|
||||
@@ -6,12 +6,14 @@ import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import mergeAssetsDirs from "../vite-multiple-assets";
|
||||
import { hmrPlugin, presets} from "vite-plugin-web-components-hmr";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/studio-frontend",
|
||||
server:{
|
||||
port: 4200,
|
||||
host: "localhost",
|
||||
hmr: true,
|
||||
},
|
||||
preview:{
|
||||
port: 4300,
|
||||
@@ -25,5 +27,9 @@ export default defineConfig({
|
||||
"studio-frontend/public",
|
||||
"dist/studio/public",
|
||||
]),
|
||||
hmrPlugin({
|
||||
include: ['./src/**/*.ts'],
|
||||
presets: [presets.lit]
|
||||
})
|
||||
],
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import farCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw";
|
||||
import farGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw";
|
||||
import farUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw";
|
||||
import fasPlay from "@fortawesome/fontawesome-pro/svgs/solid/play.svg?raw";
|
||||
import fasPause from "@fortawesome/fontawesome-pro/svgs/solid/pause.svg?raw";
|
||||
import fasFwdStep from "@fortawesome/fontawesome-pro/svgs/solid/forward-step.svg?raw";
|
||||
import fasFwdFast from "@fortawesome/fontawesome-pro/svgs/solid/forward-fast.svg?raw";
|
||||
import fasBwdStep from "@fortawesome/fontawesome-pro/svgs/solid/backward-step.svg?raw";
|
||||
@@ -100,6 +101,7 @@ function renderSvg(iconId: string) {
|
||||
"globe": () => farGlobe,
|
||||
"photo-film-music": () => farPhotoFilmMusic,
|
||||
"play": () => fasPlay,
|
||||
"pause": () => fasPause,
|
||||
"plus-large": () => fasPlusLarge,
|
||||
"question": () => fasQuestion,
|
||||
"rotate": () => farRotate,
|
||||
|
||||
@@ -49,6 +49,7 @@ bytemuck = "1.14.1"
|
||||
space_editor = {git = "https://github.com/rewin123/space_editor", rev = "ce83f86dc902787ab16213f3209276a784654df6"}
|
||||
anyhow.workspace = true
|
||||
serde_repr = "0.1.18"
|
||||
bevy_tweening = "0.9"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.11.24"
|
||||
|
||||
@@ -2,11 +2,12 @@ import fs from "node:fs";
|
||||
import url from "node:url";
|
||||
import path from "node:path";
|
||||
import { rimraf } from "rimraf";
|
||||
import which from "which";
|
||||
|
||||
import copyAssets from "./copy-assets.mjs";
|
||||
import handleError from "./handle-error.mjs";
|
||||
import run from "./run.mjs";
|
||||
import run, { wasmBindgenCliVersion } from "./run.mjs";
|
||||
|
||||
const WASM_BINDGEN_CLI_VERSION = "0.2.92";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
@@ -39,11 +40,11 @@ async function main() {
|
||||
stdio,
|
||||
});
|
||||
|
||||
const wasmBindgenInstalled = Boolean(await which("wasm-bindgen", { nothrow: true }));
|
||||
if (!wasmBindgenInstalled) {
|
||||
const wbVersion = await wasmBindgenCliVersion();
|
||||
if (!wbVersion || !wbVersion.includes(WASM_BINDGEN_CLI_VERSION)) {
|
||||
await run("cargo", [
|
||||
"install", "-f", "wasm-bindgen-cli",
|
||||
"--version", "0.2.88",
|
||||
"--version", WASM_BINDGEN_CLI_VERSION,
|
||||
], {
|
||||
shell: true,
|
||||
cwd: workspaceRoot,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import chalk from "chalk";
|
||||
import cp from "node:child_process";
|
||||
import which from "which";
|
||||
|
||||
/**
|
||||
* @param {string} command
|
||||
@@ -25,3 +26,35 @@ export default function run(command, args, options) {
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function wasmBindgenCliVersion() {
|
||||
const exePath = await which("wasm-bindgen", { nothrow: true });
|
||||
if (!exePath) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const finish = code => {
|
||||
if (code) reject(new Error(stderr));
|
||||
else resolve(stdout);
|
||||
}
|
||||
|
||||
const proc = cp.spawn(exePath, ["--version"], {
|
||||
shell: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
.on("exit", finish)
|
||||
.on("close", finish)
|
||||
.on("error", reject);
|
||||
|
||||
proc.stdout.setEncoding("utf-8");
|
||||
proc.stdout.on("data", chunk => stdout += chunk);
|
||||
|
||||
proc.stderr.setEncoding("utf-8");
|
||||
proc.stderr.on("data", chunk => stderr += chunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
use bevy::prelude::*;
|
||||
use itertools::Itertools;
|
||||
use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
|
||||
use crate::math::{InvLerp, Lerp};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
pub struct CameraAnimPlugin;
|
||||
impl Plugin for CameraAnimPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<CameraKeyframe>()
|
||||
.editor_registry::<CameraKeyframe>()
|
||||
.register_type::<CameraTargetKeyframe>()
|
||||
.editor_registry::<CameraTargetKeyframe>();
|
||||
|
||||
app.add_systems(Update, draw_camera_keyframes);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
wasm::spawn_camera_keyframes,
|
||||
wasm::notify_keyframe_changes.map(bevy::utils::error),
|
||||
wasm::process_update_keyframe,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraKeyframe(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CameraTargetKeyframe(pub f32);
|
||||
|
||||
fn draw_camera_keyframes(
|
||||
mut gizmos: Gizmos,
|
||||
r_seek_time: Res<GlobalSeekTime>,
|
||||
q_cam_keyframe_xforms: Query<&GlobalTransform, With<CameraKeyframe>>,
|
||||
q_cam_target_keyframe_xforms: Query<&GlobalTransform, With<CameraTargetKeyframe>>,
|
||||
q_cam_keyframes: Query<(&CameraKeyframe, &GlobalTransform)>,
|
||||
) {
|
||||
if q_cam_keyframes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for world_xform in q_cam_keyframe_xforms
|
||||
.iter()
|
||||
.chain(q_cam_target_keyframe_xforms.iter())
|
||||
{
|
||||
let origin = world_xform.translation();
|
||||
let x_axis = world_xform.right() * 0.25;
|
||||
let y_axis = world_xform.up() * 0.25;
|
||||
let z_axis = world_xform.back() * 0.25;
|
||||
|
||||
gizmos.line(origin, origin + x_axis, Color::RED);
|
||||
gizmos.line(origin, origin + y_axis, Color::GREEN);
|
||||
gizmos.line(origin, origin + z_axis, Color::BLUE);
|
||||
}
|
||||
|
||||
let seek_time = **r_seek_time;
|
||||
let keyframes_sorted = q_cam_keyframes
|
||||
.iter()
|
||||
.sorted_by(|(a, _), (b, _)| a.partial_cmp(&b.0).unwrap())
|
||||
.collect_vec();
|
||||
|
||||
let (timestamp_first, xform_first) = keyframes_sorted.first().unwrap();
|
||||
let (timestamp_last, xform_last) = keyframes_sorted.last().unwrap();
|
||||
|
||||
if seek_time <= timestamp_first.0 {
|
||||
let (_, rot, pos) = xform_first.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if seek_time >= timestamp_last.0 {
|
||||
let (_, rot, pos) = xform_last.to_scale_rotation_translation();
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
} else if let Some(((timestamp_a, xform_a), (timestamp_b, xform_b))) = keyframes_sorted
|
||||
.iter()
|
||||
.tuple_windows()
|
||||
.find(|((a, _), (b, _))| a.0 <= seek_time && b.0 >= seek_time)
|
||||
{
|
||||
let t = seek_time.inv_lerp(timestamp_a.0, timestamp_b.0);
|
||||
let (_, rot_a, pos_a) = xform_a.to_scale_rotation_translation();
|
||||
let (_, rot_b, pos_b) = xform_b.to_scale_rotation_translation();
|
||||
let pos = t.lerp(pos_a, pos_b);
|
||||
let rot = Quat::slerp(rot_a, rot_b, t);
|
||||
|
||||
gizmos.sphere(pos, rot, 0.25, Color::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use space_editor::prelude::space_undo::{self, AddedEntity};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{
|
||||
camera_controller::CameraController,
|
||||
wasm::{self, FromStr, StaticBuffer, WasmMessageBuffer},
|
||||
};
|
||||
|
||||
use super::{CameraKeyframe, CameraTargetKeyframe};
|
||||
|
||||
static ADD_KEYFRAME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
static UPDATE_KEYFRAME: WasmMessageBuffer<(Entity, f32)> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = addCameraKeyframe)]
|
||||
pub fn add_camera_keyframe(timestamp: f32) {
|
||||
ADD_KEYFRAME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_camera_keyframes(
|
||||
mut cmd: Commands,
|
||||
mut ew_changes: EventWriter<space_undo::NewChange>,
|
||||
q_camera: Query<(&CameraController, &GlobalTransform)>,
|
||||
) {
|
||||
if let Some(timestamp) = ADD_KEYFRAME.take_message() {
|
||||
let Ok((cam_controller, world_xform)) = q_camera.get_single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Target Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from_translation(
|
||||
cam_controller.focus,
|
||||
)),
|
||||
CameraTargetKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
|
||||
let entity = cmd
|
||||
.spawn((
|
||||
Name::new("Camera Keyframe"),
|
||||
TransformBundle::from_transform(Transform::from(*world_xform)),
|
||||
CameraKeyframe(timestamp),
|
||||
))
|
||||
.id();
|
||||
ew_changes.send(space_undo::NewChange {
|
||||
change: Arc::new(AddedEntity { entity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = updateKeyframe)]
|
||||
pub fn update_keyframe(entity: &str, timestamp: f32) {
|
||||
let entity = Entity::from_str(entity).unwrap();
|
||||
UPDATE_KEYFRAME.write_message((entity, timestamp));
|
||||
}
|
||||
|
||||
pub(super) fn process_update_keyframe(
|
||||
mut q_camera_keyframes: Query<&mut CameraKeyframe>,
|
||||
mut q_camera_target_keyframes: Query<&mut CameraTargetKeyframe>,
|
||||
) {
|
||||
if let Some((ent, timestamp)) = UPDATE_KEYFRAME.take_message() {
|
||||
if let Ok(mut keyframe) = q_camera_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else if let Ok(mut keyframe) = q_camera_target_keyframes.get_mut(ent) {
|
||||
**keyframe = timestamp;
|
||||
} else {
|
||||
warn!("No matching keyframe found for entity: {ent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const CAMERA_KEYFRAMES_EVENT: &str = r#"
|
||||
export interface CameraKeyframesEvent extends CustomEvent {
|
||||
type: "camera-keyframes";
|
||||
detail: Record<string, number>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"camera-keyframes": CameraKeyframesEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub(super) fn notify_keyframe_changes(
|
||||
mut l_notified: Local<serde_json::Map<String, serde_json::Value>>,
|
||||
q_keyframes: Query<(Entity, &CameraKeyframe)>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if l_notified.len() == q_keyframes.iter().count() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
l_notified.clear();
|
||||
for (ent, timestamp) in q_keyframes.iter() {
|
||||
l_notified.insert(format!("{ent:?}"), timestamp.0.into());
|
||||
}
|
||||
|
||||
wasm::dispatch_to_js(window, "camera-keyframes", &*l_notified)
|
||||
}
|
||||
}
|
||||
+5
-33
@@ -9,8 +9,8 @@ use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
use crate::{hierarchy, scene::SceneElement};
|
||||
pub use retargeting::*;
|
||||
|
||||
mod camera;
|
||||
mod retargeting;
|
||||
pub mod timeline_animation;
|
||||
|
||||
pub struct AnimPlugin;
|
||||
impl Plugin for AnimPlugin {
|
||||
@@ -29,19 +29,14 @@ impl Plugin for AnimPlugin {
|
||||
.editor_registry::<InvBindPose>();
|
||||
|
||||
app.add_systems(Update, process_skinned_meshes);
|
||||
app.insert_resource(GlobalSeekTime(0.));
|
||||
|
||||
app.add_plugins((retargeting::RetargetingPlugin, camera::CameraAnimPlugin));
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(Update, wasm::update_global_seek_time);
|
||||
app.add_plugins((
|
||||
retargeting::RetargetingPlugin,
|
||||
timeline_animation::TimelinePlugin,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect)]
|
||||
#[reflect(Resource)]
|
||||
pub struct GlobalSeekTime(pub f32);
|
||||
|
||||
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct Skeleton;
|
||||
@@ -149,26 +144,3 @@ fn process_skinned_meshes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
|
||||
|
||||
use super::GlobalSeekTime;
|
||||
|
||||
static SEEK_TIME: WasmMessageBuffer<f32> = WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = seekTime)]
|
||||
pub fn seek_time(timestamp: f32) {
|
||||
SEEK_TIME.write_message(timestamp);
|
||||
}
|
||||
|
||||
pub(super) fn update_global_seek_time(mut r_seek_time: ResMut<GlobalSeekTime>) {
|
||||
if let Some(timestamp) = SEEK_TIME.take_message() {
|
||||
**r_seek_time = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::{
|
||||
ecs::{
|
||||
entity::{EntityMapper, MapEntities},
|
||||
reflect::ReflectMapEntities,
|
||||
},
|
||||
prelude::*,
|
||||
utils::HashSet,
|
||||
};
|
||||
|
||||
use bevy_tweening::{
|
||||
lens::{TransformPositionLens, TransformRotationLens, TransformScaleLens},
|
||||
Animator, EaseMethod, Sequence, Targetable, Tracks, Tween, TweenCompleted,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use space_editor::space_prefab::editor_registry::EditorRegistryExt;
|
||||
|
||||
use crate::interaction::{MarkedForDeletion, Selectable};
|
||||
|
||||
pub struct TimelinePlugin;
|
||||
|
||||
impl Plugin for TimelinePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<TimedAnimationClipAsset>()
|
||||
.editor_registry::<TimedAnimationClipAsset>()
|
||||
.register_type::<AnimationTarget>()
|
||||
.editor_registry::<AnimationTarget>()
|
||||
.register_type::<TransformKeyframe>()
|
||||
.editor_registry::<TransformKeyframe>()
|
||||
.register_type::<Keyframe>()
|
||||
.editor_registry::<Keyframe>()
|
||||
.register_type::<KeyframeOrder>()
|
||||
.editor_registry::<KeyframeOrder>()
|
||||
.add_event::<TimelineInteractionEvent>()
|
||||
.add_event::<KeyframeInteractionEvent>();
|
||||
|
||||
app.insert_resource(GlobalSeekTime {
|
||||
time: 0.,
|
||||
playing: false,
|
||||
delta: None,
|
||||
})
|
||||
.add_event::<TweenCompleted>();
|
||||
|
||||
app.add_systems(PreUpdate, (process_timeline_events, update_tick).chain());
|
||||
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
process_keyframe_events,
|
||||
timeline_bound_component_animator_system::<Transform>,
|
||||
),
|
||||
);
|
||||
|
||||
app.add_systems(PostUpdate, generate_transform_animation);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
app.add_systems(
|
||||
First,
|
||||
(
|
||||
wasm::update_global_seek_time,
|
||||
wasm::update_keyframe_interaction,
|
||||
),
|
||||
);
|
||||
app.add_systems(
|
||||
PostUpdate,
|
||||
(
|
||||
wasm::notify_seek_time_changes.map(bevy::utils::error),
|
||||
wasm::notify_keyframe_changes.map(bevy::utils::error),
|
||||
wasm::notify_updated_animation_targets.map(bevy::utils::error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_KEYFRAME_DISTANCE: f32 = 0.01;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Event, Clone, Copy, Debug)]
|
||||
pub enum TimelineInteractionEvent {
|
||||
Play,
|
||||
Pause,
|
||||
Seek(f32),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Event, Clone, Copy, Debug)]
|
||||
pub enum KeyframeInteractionEvent {
|
||||
RecordKeyframe { target: Entity },
|
||||
MoveKeyframeTime { target: Entity, time: f32 },
|
||||
AddEntityTrack { target: Entity },
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize)]
|
||||
#[reflect(Resource)]
|
||||
pub struct GlobalSeekTime {
|
||||
pub time: f32,
|
||||
pub playing: bool,
|
||||
pub delta: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Default, Reflect, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EaseType {
|
||||
#[default]
|
||||
Linear,
|
||||
CubicHermite,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Reflect, PartialEq)]
|
||||
#[reflect(Component)]
|
||||
pub struct TransformKeyframe {
|
||||
incoming_easing: EaseType,
|
||||
affect_translation: bool,
|
||||
affect_rotation: bool,
|
||||
affect_scale: bool,
|
||||
}
|
||||
|
||||
impl Default for TransformKeyframe {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incoming_easing: Default::default(),
|
||||
affect_translation: true,
|
||||
affect_rotation: true,
|
||||
affect_scale: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Reflect, PartialEq)]
|
||||
#[reflect(Component, MapEntities)]
|
||||
pub struct Keyframe {
|
||||
target: Entity,
|
||||
time: f32,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Default, Reflect, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[reflect(Component)]
|
||||
pub struct KeyframeOrder(u32);
|
||||
|
||||
impl PartialOrd for Keyframe {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
|
||||
impl MapEntities for Keyframe {
|
||||
fn map_entities(&mut self, entity_mapper: &mut EntityMapper) {
|
||||
self.target = entity_mapper.get_or_reserve(self.target);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Keyframe {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: Entity::PLACEHOLDER,
|
||||
time: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Reflect, PartialEq)]
|
||||
#[reflect(Component)]
|
||||
pub struct TimedAnimationClipAsset {
|
||||
clip: Handle<AnimationClip>,
|
||||
duration: f32,
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
impl PartialOrd for TimedAnimationClipAsset {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
match self.duration.partial_cmp(&other.duration) {
|
||||
Some(core::cmp::Ordering::Equal) => {}
|
||||
ord => return ord,
|
||||
}
|
||||
match self.speed.partial_cmp(&other.speed) {
|
||||
Some(core::cmp::Ordering::Equal) => {}
|
||||
ord => return ord,
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimedAnimationClipAsset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clip: Default::default(),
|
||||
duration: Default::default(),
|
||||
speed: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Default, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct AnimationTarget;
|
||||
|
||||
fn process_timeline_events(
|
||||
mut e_timeline_event: EventReader<TimelineInteractionEvent>,
|
||||
mut r_global_seek_time: ResMut<GlobalSeekTime>,
|
||||
) {
|
||||
for event in e_timeline_event.read() {
|
||||
match event {
|
||||
TimelineInteractionEvent::Play => r_global_seek_time.playing = true,
|
||||
TimelineInteractionEvent::Pause => {
|
||||
r_global_seek_time.playing = false;
|
||||
}
|
||||
TimelineInteractionEvent::Seek(time) => {
|
||||
r_global_seek_time.time = time.max(0.);
|
||||
}
|
||||
}
|
||||
|
||||
r_global_seek_time.delta = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_tick(mut r_global_seek_time: ResMut<GlobalSeekTime>, time: Res<Time>) {
|
||||
if r_global_seek_time.playing {
|
||||
let delta = time.delta_seconds();
|
||||
r_global_seek_time.time += delta;
|
||||
r_global_seek_time.delta = Some(delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_keyframe_events(
|
||||
mut commands: Commands,
|
||||
mut e_timeline_event: EventReader<KeyframeInteractionEvent>,
|
||||
r_global_seek_time: Res<GlobalSeekTime>,
|
||||
q_animation_targets: Query<(Entity, &Transform), With<AnimationTarget>>,
|
||||
q_potential_targets: Query<Entity, (With<Transform>, Without<AnimationTarget>)>,
|
||||
mut q_keyframes: Query<(Entity, &mut Keyframe)>,
|
||||
) {
|
||||
for event in e_timeline_event.read() {
|
||||
info!("Processing Keyframe Event {event:?}");
|
||||
match event {
|
||||
KeyframeInteractionEvent::RecordKeyframe { target } => {
|
||||
if let Ok((target, transform)) = q_animation_targets.get(*target) {
|
||||
let mut existing = None;
|
||||
for (entity, keyframe) in q_keyframes.iter_mut() {
|
||||
if keyframe.target == target
|
||||
&& (keyframe.time - r_global_seek_time.time).abs()
|
||||
< MIN_KEYFRAME_DISTANCE
|
||||
{
|
||||
existing = Some(entity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(existing) = existing {
|
||||
commands.entity(existing).insert((
|
||||
*transform,
|
||||
TransformKeyframe::default(),
|
||||
Keyframe {
|
||||
target,
|
||||
time: r_global_seek_time.time,
|
||||
},
|
||||
));
|
||||
} else {
|
||||
commands.spawn((
|
||||
SpatialBundle {
|
||||
transform: *transform,
|
||||
..Default::default()
|
||||
},
|
||||
Selectable,
|
||||
Keyframe {
|
||||
target,
|
||||
time: r_global_seek_time.time,
|
||||
},
|
||||
TransformKeyframe::default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyframeInteractionEvent::MoveKeyframeTime { target, time } => {
|
||||
if let Ok((_, mut keyframe)) = q_keyframes.get_mut(*target) {
|
||||
keyframe.time = *time;
|
||||
}
|
||||
}
|
||||
KeyframeInteractionEvent::AddEntityTrack { target } => {
|
||||
if let Ok(target) = q_potential_targets.get(*target) {
|
||||
info!("Adding Animation Target to Entity");
|
||||
commands.entity(target).insert(AnimationTarget);
|
||||
} else {
|
||||
warn!("Can't add animation target to entity");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_transform_animation(
|
||||
mut commands: Commands,
|
||||
changed: Query<
|
||||
&Keyframe,
|
||||
(
|
||||
With<TransformKeyframe>,
|
||||
Or<(Changed<Keyframe>, Changed<Transform>)>,
|
||||
),
|
||||
>,
|
||||
keyframes: Query<
|
||||
(&Keyframe, &Transform, &TransformKeyframe, Entity),
|
||||
Without<MarkedForDeletion>,
|
||||
>,
|
||||
removed: Query<&Keyframe, With<MarkedForDeletion>>,
|
||||
) {
|
||||
let changed_targets = changed
|
||||
.iter()
|
||||
.map(|keyframe| keyframe.target)
|
||||
.chain(removed.iter().map(|keyframe| {
|
||||
info!("Processing a removed keyframe {keyframe:?}");
|
||||
keyframe.target
|
||||
}))
|
||||
.collect::<HashSet<_>>();
|
||||
let keyframes = keyframes
|
||||
.iter()
|
||||
.filter(|(keyframe, _, _, _)| changed_targets.contains(&keyframe.target))
|
||||
.group_by(|(keyframe, _, _, _)| keyframe.target);
|
||||
|
||||
for (target, keyframes) in &keyframes {
|
||||
let keyframes = keyframes.sorted_by(|a, b| a.0.time.total_cmp(&b.0.time));
|
||||
|
||||
let (_, _, _, translation, rotation, scale) = keyframes.fold(
|
||||
(
|
||||
Option::<(f32, Vec3)>::None,
|
||||
Option::<(f32, Quat)>::None,
|
||||
Option::<(f32, Vec3)>::None,
|
||||
Vec::<Tween<Transform>>::new(),
|
||||
Vec::<Tween<Transform>>::new(),
|
||||
Vec::<Tween<Transform>>::new(),
|
||||
),
|
||||
|(
|
||||
last_translation,
|
||||
last_rotation,
|
||||
last_scale,
|
||||
mut translation_list,
|
||||
mut rotation_list,
|
||||
mut scale_list,
|
||||
),
|
||||
(keyframe, transform, transform_keyframe, _)| {
|
||||
let time = keyframe.time;
|
||||
let translation = transform.translation;
|
||||
let rotation = transform.rotation;
|
||||
let scale = transform.scale;
|
||||
|
||||
info!("Processing Keyframe: {time} previous: {last_rotation:?}");
|
||||
|
||||
if transform_keyframe.affect_translation {
|
||||
let (last_time, start) = match last_translation {
|
||||
Some(v) => v,
|
||||
None => (0., translation),
|
||||
};
|
||||
info!("Should apply translation");
|
||||
if (time - last_time).abs() > 0.00001 {
|
||||
translation_list.push(Tween::new(
|
||||
EaseMethod::Linear,
|
||||
Duration::from_secs_f32((time - last_time).max(0.00001)),
|
||||
TransformPositionLens {
|
||||
start,
|
||||
end: translation,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if transform_keyframe.affect_rotation {
|
||||
let (last_time, start) = match last_rotation {
|
||||
Some(v) => v,
|
||||
None => (0., rotation),
|
||||
};
|
||||
|
||||
info!("Should apply rotation");
|
||||
|
||||
if (time - last_time).abs() > 0.00001 {
|
||||
rotation_list.push(Tween::new(
|
||||
EaseMethod::Linear,
|
||||
Duration::from_secs_f32((time - last_time).max(0.00001)),
|
||||
TransformRotationLens {
|
||||
start,
|
||||
end: rotation,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if transform_keyframe.affect_scale {
|
||||
let (last_time, start) = match last_scale {
|
||||
Some(v) => v,
|
||||
None => (0., scale),
|
||||
};
|
||||
info!("Should apply scale");
|
||||
if (time - last_time).abs() > 0.00001 {
|
||||
scale_list.push(Tween::new(
|
||||
EaseMethod::Linear,
|
||||
Duration::from_secs_f32((time - last_time).max(0.00001)),
|
||||
TransformScaleLens { start, end: scale },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let next_translation = if transform_keyframe.affect_translation {
|
||||
Some((time, translation))
|
||||
} else {
|
||||
last_translation
|
||||
};
|
||||
let next_rotation = if transform_keyframe.affect_rotation {
|
||||
Some((time, rotation))
|
||||
} else {
|
||||
last_rotation
|
||||
};
|
||||
let next_scale = if transform_keyframe.affect_scale {
|
||||
Some((time, scale))
|
||||
} else {
|
||||
last_scale
|
||||
};
|
||||
|
||||
(
|
||||
next_translation,
|
||||
next_rotation,
|
||||
next_scale,
|
||||
translation_list,
|
||||
rotation_list,
|
||||
scale_list,
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let mut tracks = Vec::with_capacity(3);
|
||||
|
||||
if !translation.is_empty() {
|
||||
tracks.push(Sequence::new(translation));
|
||||
} else {
|
||||
info!("No translation");
|
||||
}
|
||||
if !rotation.is_empty() {
|
||||
tracks.push(Sequence::new(rotation));
|
||||
} else {
|
||||
info!("No rotation");
|
||||
}
|
||||
if !scale.is_empty() {
|
||||
tracks.push(Sequence::new(scale));
|
||||
} else {
|
||||
info!("No scale");
|
||||
}
|
||||
|
||||
if tracks.is_empty() {
|
||||
commands.entity(target).remove::<Animator<Transform>>();
|
||||
} else {
|
||||
let tracks = Tracks::new(tracks);
|
||||
|
||||
commands.entity(target).insert(Animator::new(tracks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentTarget<'a, T: Component>(&'a mut T);
|
||||
|
||||
impl<'a, T: Component> ComponentTarget<'a, T> {
|
||||
fn new(target: Mut<'a, T>) -> Self {
|
||||
Self(target.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: Component> Targetable<T> for ComponentTarget<'a, T> {
|
||||
fn target_mut(&mut self) -> &mut T {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timeline_bound_component_animator_system<T: Component>(
|
||||
time: Res<GlobalSeekTime>,
|
||||
mut query: Query<(Entity, &mut T, &mut Animator<T>)>,
|
||||
events: ResMut<Events<TweenCompleted>>,
|
||||
) {
|
||||
if time.is_changed() && time.time >= 0.0 {
|
||||
info!("Animating with time: {time:?}");
|
||||
let mut events: Mut<Events<TweenCompleted>> = events.into();
|
||||
for (entity, target, mut animator) in query.iter_mut() {
|
||||
if animator.tweenable().duration() <= Duration::from_secs_f32(0.0001) {
|
||||
continue;
|
||||
}
|
||||
let mut target = ComponentTarget::new(target);
|
||||
if let Some(delta) = time.delta {
|
||||
info!("Playing animation with delta {delta}");
|
||||
animator.tweenable_mut().tick(
|
||||
Duration::from_secs_f32(delta),
|
||||
&mut target,
|
||||
entity,
|
||||
&mut events,
|
||||
);
|
||||
} else {
|
||||
info!("setting tween time to {}", time.time);
|
||||
let tween = animator.tweenable_mut();
|
||||
tween.set_elapsed(Duration::from_secs_f32(time.time.max(0.)));
|
||||
info!("Tween time set successfully");
|
||||
tween.tick(Duration::ZERO, &mut target, entity, &mut events);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::{prelude::*, utils::hashbrown::HashMap, window::PrimaryWindow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::{
|
||||
interaction::MarkedForDeletion,
|
||||
scene::SceneElement,
|
||||
wasm::{FromStr, StaticBuffer, WasmMessageBuffer},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent,
|
||||
TimedAnimationClipAsset, TimelineInteractionEvent, TransformKeyframe,
|
||||
};
|
||||
|
||||
static SEEK_TIME: WasmMessageBuffer<TimelineInteractionEvent> = WasmMessageBuffer::new();
|
||||
static KEYFRAME_EVENT: WasmMessageBuffer<Vec<KeyframeInteractionEvent>> =
|
||||
WasmMessageBuffer::new();
|
||||
|
||||
#[wasm_bindgen(js_name = seekTime)]
|
||||
pub fn seek_time(timestamp: f32) {
|
||||
SEEK_TIME.write_message(TimelineInteractionEvent::Seek(timestamp));
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = play)]
|
||||
pub fn play() {
|
||||
SEEK_TIME.write_message(TimelineInteractionEvent::Play);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = pause)]
|
||||
pub fn pause() {
|
||||
SEEK_TIME.write_message(TimelineInteractionEvent::Pause);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = recordKeyframe)]
|
||||
pub fn record_keyframe(entities: Vec<String>) {
|
||||
KEYFRAME_EVENT.write_message(
|
||||
entities
|
||||
.iter()
|
||||
.filter_map(|entity| Entity::from_str(entity).ok())
|
||||
.map(|target| KeyframeInteractionEvent::RecordKeyframe { target })
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = moveKeyframeTime)]
|
||||
pub fn move_keyframe_time(entity: String, time: f32) {
|
||||
let Ok(target) = Entity::from_str(&entity) else {
|
||||
return;
|
||||
};
|
||||
KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::MoveKeyframeTime {
|
||||
target,
|
||||
time,
|
||||
}]);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = addEntityTrack)]
|
||||
pub fn add_entity_track(entity: String) {
|
||||
let Ok(target) = Entity::from_str(&entity) else {
|
||||
return;
|
||||
};
|
||||
KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::AddEntityTrack { target }]);
|
||||
}
|
||||
|
||||
pub(super) fn update_global_seek_time(
|
||||
mut e_timeline_event: EventWriter<TimelineInteractionEvent>,
|
||||
) {
|
||||
if let Some(timestamp) = SEEK_TIME.take_message() {
|
||||
e_timeline_event.send(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_keyframe_interaction(
|
||||
mut e_keyframe_event: EventWriter<KeyframeInteractionEvent>,
|
||||
) {
|
||||
if let Some(keyframes) = KEYFRAME_EVENT.take_message() {
|
||||
for keyframe in keyframes.into_iter() {
|
||||
e_keyframe_event.send(keyframe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TIMELINE_TYPES: &str = r#"
|
||||
export interface SeekTimeChanged extends CustomEvent {
|
||||
type: "seek-time-changed";
|
||||
detail: {
|
||||
time: number,
|
||||
playing: boolean
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateAnimationTargets extends CustomEvent {
|
||||
type: "update-animation-targets",
|
||||
detail: {
|
||||
added: Record<string, { name?: string, target_type: SceneElement } >,
|
||||
removed: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransformKeyframeInfo {
|
||||
target: string,
|
||||
time: number,
|
||||
entity: string,
|
||||
incoming_easing: "linear" | "cubic-hermite",
|
||||
affect_translation: bool,
|
||||
affect_rotation: bool,
|
||||
affect_scale: bool
|
||||
}
|
||||
|
||||
export interface TimedAnimationClipInfo {
|
||||
target: string,
|
||||
time: number,
|
||||
entity: string,
|
||||
type: 'animation-clip',
|
||||
clip: string,
|
||||
duration: number,
|
||||
speed: number
|
||||
}
|
||||
|
||||
export type Keyframe = TransformKeyframeInfo | TimedAnimationClipInfo;
|
||||
|
||||
export interface UpdateKeyframes extends CustomEvent {
|
||||
type: "update-keyframes",
|
||||
detail: {
|
||||
added: Record<string, Keyframe>,
|
||||
removed: string[]
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"seek-time-changed": CameraKeyframesEvent;
|
||||
"update-animation-targets": UpdateAnimationTargets;
|
||||
"update-keyframes": UpdateKeyframes;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AddedAnimationTargetRecordItem {
|
||||
name: Option<String>,
|
||||
target_type: SceneElement,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct UpdatedAnimationTargetsEvent {
|
||||
added: HashMap<String, AddedAnimationTargetRecordItem>,
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TimedAnimationClipInfo {
|
||||
entity: String,
|
||||
target: String,
|
||||
time: f32,
|
||||
clip: String,
|
||||
duration: f32,
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TransformKeframeInfo {
|
||||
entity: String,
|
||||
target: String,
|
||||
time: f32,
|
||||
incoming_easing: EaseType,
|
||||
affect_translation: bool,
|
||||
affect_rotation: bool,
|
||||
affect_scale: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct UpdateKeyframes {
|
||||
added: HashMap<String, serde_json::Value>,
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) fn notify_updated_animation_targets(
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
q_cameras: Query<(Entity, Option<&Name>), (Added<AnimationTarget>, With<Camera>)>,
|
||||
q_scene_elements: Query<
|
||||
(Entity, Option<&Name>, &SceneElement),
|
||||
(Added<AnimationTarget>, Without<Camera>),
|
||||
>,
|
||||
q_transforms: Query<
|
||||
(Entity, Option<&Name>),
|
||||
(
|
||||
Added<AnimationTarget>,
|
||||
With<Transform>,
|
||||
Without<Camera>,
|
||||
Without<SceneElement>,
|
||||
),
|
||||
>,
|
||||
mut removed: bevy::prelude::RemovedComponents<AnimationTarget>,
|
||||
) -> Result<(), String> {
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let added = q_transforms
|
||||
.iter()
|
||||
.map(|(entity, name)| {
|
||||
(
|
||||
format!("{entity:?}"),
|
||||
AddedAnimationTargetRecordItem {
|
||||
name: name.map(|v| v.to_string()),
|
||||
target_type: SceneElement::Generic,
|
||||
},
|
||||
)
|
||||
})
|
||||
.chain(q_cameras.iter().map(|(entity, name)| {
|
||||
(
|
||||
format!("{entity:?}"),
|
||||
AddedAnimationTargetRecordItem {
|
||||
name: name.map(|v| v.to_string()),
|
||||
target_type: SceneElement::Camera,
|
||||
},
|
||||
)
|
||||
}))
|
||||
.chain(q_scene_elements.iter().map(|(entity, name, element)| {
|
||||
(
|
||||
format!("{entity:?}"),
|
||||
AddedAnimationTargetRecordItem {
|
||||
name: name.map(|v| v.to_string()),
|
||||
target_type: *element,
|
||||
},
|
||||
)
|
||||
}))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let removed = removed.read().map(|v| format!("{v:?}")).collect::<Vec<_>>();
|
||||
|
||||
if added.is_empty() && removed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let event = UpdatedAnimationTargetsEvent { added, removed };
|
||||
|
||||
crate::wasm::dispatch_to_js(
|
||||
window,
|
||||
"update-animation-targets",
|
||||
&serde_json::to_value(&event).map_err(|e| format!("{e}"))?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn notify_keyframe_changes(
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
q_transform_keyframes: Query<
|
||||
(Entity, &Keyframe, &TransformKeyframe),
|
||||
Or<(Changed<Keyframe>, Changed<TransformKeyframe>)>,
|
||||
>,
|
||||
q_clips: Query<
|
||||
(Entity, &Keyframe, &TimedAnimationClipAsset),
|
||||
Or<(Changed<Keyframe>, Changed<TimedAnimationClipAsset>)>,
|
||||
>,
|
||||
removed: Query<Entity, With<MarkedForDeletion>>,
|
||||
) -> Result<(), String> {
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let added = q_transform_keyframes
|
||||
.iter()
|
||||
.map(|(entity, keyframe, transform_keyframe)| {
|
||||
match serde_json::to_value(TransformKeframeInfo {
|
||||
entity: format!("{entity:?}"),
|
||||
target: format!("{:?}", keyframe.target),
|
||||
time: keyframe.time,
|
||||
incoming_easing: transform_keyframe.incoming_easing,
|
||||
affect_translation: transform_keyframe.affect_translation,
|
||||
affect_rotation: transform_keyframe.affect_rotation,
|
||||
affect_scale: transform_keyframe.affect_scale,
|
||||
})
|
||||
.map_err(|e| format!("{e}"))
|
||||
{
|
||||
Ok(value) => Ok((format!("{entity:?}"), value)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
.chain(q_clips.iter().map(|(entity, keyframe, clip)| {
|
||||
match serde_json::to_value(TimedAnimationClipInfo {
|
||||
entity: format!("{entity:?}"),
|
||||
target: format!("{:?}", keyframe.target),
|
||||
time: keyframe.time,
|
||||
clip: clip.clip.id().to_string(),
|
||||
duration: clip.duration,
|
||||
speed: clip.speed,
|
||||
})
|
||||
.map_err(|e| format!("{e}"))
|
||||
{
|
||||
Ok(value) => Ok((format!("{entity:?}"), value)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}))
|
||||
.collect::<Result<HashMap<_, _>, String>>()?;
|
||||
|
||||
let removed = removed.iter().map(|v| format!("{v:?}")).collect::<Vec<_>>();
|
||||
|
||||
if added.is_empty() && removed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let event = UpdateKeyframes { added, removed };
|
||||
|
||||
info!("Updating keyframes: {event:?}");
|
||||
|
||||
crate::wasm::dispatch_to_js(
|
||||
window,
|
||||
"update-keyframes",
|
||||
&serde_json::to_value(&event).map_err(|e| format!("{e}"))?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn notify_seek_time_changes(
|
||||
r_global_seek_time: Res<GlobalSeekTime>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result<(), String> {
|
||||
if !r_global_seek_time.is_changed() {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
info!("Current Seek Time: {r_global_seek_time:?}");
|
||||
|
||||
crate::wasm::dispatch_to_js(
|
||||
window,
|
||||
"seek-time-changed",
|
||||
&serde_json::to_value(r_global_seek_time.as_ref()).map_err(|e| format!("{e}"))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ impl Plugin for SelectionPlugin {
|
||||
PostUpdate,
|
||||
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
|
||||
)
|
||||
.add_systems(First, finalize_delete)
|
||||
.add_systems(
|
||||
OnEnter(SceneState::Active),
|
||||
(apply_deferred, tag_scene_elements)
|
||||
@@ -158,6 +159,9 @@ fn update_selection(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct MarkedForDeletion;
|
||||
|
||||
fn process_delete_inputs(
|
||||
mut cmd: Commands,
|
||||
mut er_input: EventReader<InputAction>,
|
||||
@@ -172,7 +176,7 @@ fn process_delete_inputs(
|
||||
}) {
|
||||
let entity = **r_selection;
|
||||
|
||||
cmd.entity(entity).despawn_recursive();
|
||||
cmd.entity(entity).insert(MarkedForDeletion);
|
||||
cmd.remove_resource::<Selection>();
|
||||
ew_changes.send(NewChange {
|
||||
change: Arc::new(RemovedEntity { entity }),
|
||||
@@ -180,6 +184,12 @@ fn process_delete_inputs(
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_delete(mut cmd: Commands, q: Query<Entity, With<MarkedForDeletion>>) {
|
||||
for entity in &q {
|
||||
cmd.entity(entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_selection_outline(
|
||||
mut cmd: Commands,
|
||||
q_selected: Query<&Children, With<Selected>>,
|
||||
|
||||
@@ -218,7 +218,7 @@ pub(crate) fn update_camera_motion(
|
||||
// main_cam_transform.look_at(Vec3::ZERO, Vec3::Y);
|
||||
}
|
||||
|
||||
fn zoom_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed : f32) {
|
||||
fn zoom_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed: f32) {
|
||||
let scaled_time = time.elapsed().as_secs_f32() * speed;
|
||||
let normalized_cos = (-scaled_time.cos() + 1.0) / 2.0;
|
||||
let start_pos = Vec3::new(0.366, 1.1, 2.5);
|
||||
@@ -228,7 +228,7 @@ fn zoom_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed : f32)
|
||||
main_cam_transform.rotation = Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
fn pan_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed : f32) {
|
||||
fn pan_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed: f32) {
|
||||
let scaled_time = time.elapsed().as_secs_f32() * speed;
|
||||
let normalized_cos = (-scaled_time.cos() + 1.0) / 2.0;
|
||||
let start_pos = Vec3::new(0.676, 1.1, 0.119);
|
||||
@@ -238,7 +238,7 @@ fn pan_camera(main_cam_transform: &mut Transform, time: &Time<()>, speed : f32)
|
||||
main_cam_transform.rotation = Quat::from_euler(EulerRot::XYZ, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
fn orbit_camera(main_cam_transform: &mut Mut<'_, Transform>, time: &Res<'_, Time>, speed : f32) {
|
||||
fn orbit_camera(main_cam_transform: &mut Mut<'_, Transform>, time: &Res<'_, Time>, speed: f32) {
|
||||
let scaled_time = time.elapsed().as_secs_f32() * speed;
|
||||
let max_angle = std::f32::consts::PI / 2.0;
|
||||
let normalized_cos = (scaled_time.cos() + 1.0) / 2.0;
|
||||
|
||||
@@ -23,6 +23,7 @@ use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use wgpu::{TextureDescriptor, TextureUsages};
|
||||
|
||||
use crate::{
|
||||
anim::timeline_animation::AnimationTarget,
|
||||
camera_controller::{CameraController, CameraControllerBundle},
|
||||
change_tracking::{ChangeTracker, ChangeTrackingPlugin},
|
||||
debug::DebugPoint,
|
||||
@@ -105,6 +106,7 @@ impl Plugin for ScenePlugin {
|
||||
fn minimal_scene_setup(mut cmd: Commands) {
|
||||
cmd.spawn((
|
||||
Name::new("Directional Light"),
|
||||
AnimationTarget,
|
||||
DirectionalLightBundle {
|
||||
directional_light: DirectionalLight {
|
||||
shadows_enabled: false,
|
||||
@@ -260,6 +262,7 @@ fn initialize(mut cmd: Commands) {
|
||||
cmd.spawn((
|
||||
Name::new("Main Camera"),
|
||||
MainCamera,
|
||||
AnimationTarget,
|
||||
ChangeTracker,
|
||||
Camera3dBundle {
|
||||
transform: camera_xform,
|
||||
@@ -316,6 +319,7 @@ fn initialiaze_headless(
|
||||
cmd.spawn((
|
||||
Name::new("Main Camera"),
|
||||
MainCamera,
|
||||
AnimationTarget,
|
||||
Camera3dBundle {
|
||||
transform: camera_xform,
|
||||
tonemapping: Tonemapping::BlenderFilmic,
|
||||
|
||||
@@ -144,9 +144,11 @@ fn update_screen_capture_on_extract_step(
|
||||
fn extract_screen_capture_components(
|
||||
mut commands: Commands,
|
||||
screen_capure_comps: Extract<Query<(Entity, &ScreenCapture), With<Camera>>>,
|
||||
scene_state : Extract<Res<State<SceneState>>>
|
||||
scene_state: Extract<Res<State<SceneState>>>,
|
||||
) {
|
||||
if scene_state.get() != &SceneState::Active { return };
|
||||
if scene_state.get() != &SceneState::Active {
|
||||
return;
|
||||
};
|
||||
for (entity, screen_capture) in screen_capure_comps.iter() {
|
||||
if screen_capture.should_capture() {
|
||||
info!(
|
||||
@@ -252,25 +254,24 @@ impl ViewNode for ScreenCaptureNode {
|
||||
//==============================================================================
|
||||
|
||||
fn save_buffer_to_file(
|
||||
mut frames_to_skip : Local<Option<u32>>,
|
||||
mut frames_to_skip: Local<Option<u32>>,
|
||||
buffers: Query<(&ScreenCapture, &ScreenCaptureBuffers)>,
|
||||
device: Res<RenderDevice>,
|
||||
) {
|
||||
for (screen_capture, buffers) in buffers.iter() {
|
||||
|
||||
// ALERT!! ALERT!! Filthy Hack in effect
|
||||
// Engine does not load scene in time for the first 3 frames
|
||||
// So we skip them. Oops :(
|
||||
// TODO: Fix the Race Condition and properly take screenshots.
|
||||
let frames_skipped = frames_to_skip.unwrap_or(3);
|
||||
|
||||
|
||||
if frames_skipped > 0 {
|
||||
println!("Skipping Frame");
|
||||
*frames_to_skip = Some(frames_skipped - 1);
|
||||
return;
|
||||
}
|
||||
// Filthy Hack Ends
|
||||
|
||||
|
||||
if buffers.main_bytes_per_row != buffers.main_padded_bytes_per_row {
|
||||
println!("These don't match!");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user