split up demo modules, improve death support

This commit is contained in:
Brian Beck 2026-03-01 08:33:38 -08:00
parent 359a036558
commit c5b43f2e55
39 changed files with 2269 additions and 3942 deletions

View file

@ -248,14 +248,33 @@ input[type="range"] {
.PlayerNameplate {
pointer-events: none;
text-align: center;
display: inline-flex;
flex-direction: column;
align-items: center;
white-space: nowrap;
}
.PlayerTop {
padding-bottom: 20px;
}
.PlayerBottom {
padding-top: 20px;
}
.PlayerNameplate-iffArrow {
width: 12px;
height: 12px;
image-rendering: pixelated;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
}
.PlayerNameplate-name {
color: #fff;
font-size: 11px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 1px rgba(0, 0, 0, 0.7);
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 1px rgba(0, 0, 0, 0.7);
}
.PlayerNameplate-healthBar {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,10 @@
1:"$Sreact.fragment"
2:I[47257,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ClientPageRoot"]
3:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/12e7daed7311216f.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
3:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/ad5913f83864409c.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
6:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"OutletBoundary"]
7:"$Sreact.suspense"
:HL["/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","style"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/12e7daed7311216f.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/ad5913f83864409c.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
8:null

View file

@ -3,15 +3,15 @@
3:I[39756,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ClientPageRoot"]
6:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/12e7daed7311216f.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
6:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/ad5913f83864409c.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"OutletBoundary"]
a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
:HL["/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","style"]
0:{"P":null,"b":"TA1NEd7uhnyFsTRk4wMjb","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/12e7daed7311216f.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10",[]],"S":true}
0:{"P":null,"b":"A7b21KJnATF7QS7o5ziIf","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/ad5913f83864409c.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10",[]],"S":true}
7:{}
8:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]

View file

@ -3,4 +3,4 @@
3:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"IconMark"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"MapGenius  Explore maps for Tribes 2"}],["$","meta","1",{"name":"description","content":"Tribes 2 forever."}],["$","link","2",{"rel":"icon","href":"/t2-mapper/icon.png?icon.2911bba1.png","sizes":"108x128","type":"image/png"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"MapGenius  Explore maps for Tribes 2"}],["$","meta","1",{"name":"description","content":"Tribes 2 forever."}],["$","link","2",{"rel":"icon","href":"/t2-mapper/icon.png?icon.2911bba1.png","sizes":"108x128","type":"image/png"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}

View file

@ -2,5 +2,5 @@
2:I[12985,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js"],"NuqsAdapter"]
3:I[39756,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","template":["$","$L4",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]]}],"loading":null,"isPartial":false}
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","template":["$","$L4",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]]}],"loading":null,"isPartial":false}

View file

@ -1,3 +1,3 @@
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
:HL["/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","style"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -7,8 +7,8 @@
8:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ViewportBoundary"]
a:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
c:I[68027,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
0:{"P":null,"b":"TA1NEd7uhnyFsTRk4wMjb","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c","$undefined"],"S":true}
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
0:{"P":null,"b":"A7b21KJnATF7QS7o5ziIf","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c","$undefined"],"S":true}
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]
d:I[27201,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"IconMark"]
7:null

View file

@ -3,4 +3,4 @@
3:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"IconMark"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"MapGenius  Explore maps for Tribes 2"}],["$","meta","1",{"name":"description","content":"Tribes 2 forever."}],["$","link","2",{"rel":"icon","href":"/t2-mapper/icon.png?icon.2911bba1.png","sizes":"108x128","type":"image/png"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"MapGenius  Explore maps for Tribes 2"}],["$","meta","1",{"name":"description","content":"Tribes 2 forever."}],["$","link","2",{"rel":"icon","href":"/t2-mapper/icon.png?icon.2911bba1.png","sizes":"108x128","type":"image/png"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}

View file

@ -2,5 +2,5 @@
2:I[12985,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js"],"NuqsAdapter"]
3:I[39756,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","template":["$","$L4",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]]}],"loading":null,"isPartial":false}
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","template":["$","$L4",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]]}],"loading":null,"isPartial":false}

View file

@ -1,5 +1,5 @@
1:"$Sreact.fragment"
2:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"OutletBoundary"]
3:"$Sreact.suspense"
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"loading":null,"isPartial":false}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"loading":null,"isPartial":false}
4:null

View file

@ -1,4 +1,4 @@
1:"$Sreact.fragment"
2:I[39756,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
3:I[37457,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"loading":null,"isPartial":false}
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"loading":null,"isPartial":false}

View file

@ -1,2 +1,2 @@
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
0:{"buildId":"TA1NEd7uhnyFsTRk4wMjb","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"/_not-found","paramType":null,"paramKey":"/_not-found","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
0:{"buildId":"A7b21KJnATF7QS7o5ziIf","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"/_not-found","paramType":null,"paramKey":"/_not-found","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}

File diff suppressed because one or more lines are too long

View file

@ -7,8 +7,8 @@
8:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ViewportBoundary"]
a:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
c:I[68027,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
0:{"P":null,"b":"TA1NEd7uhnyFsTRk4wMjb","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c","$undefined"],"S":true}
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
0:{"P":null,"b":"A7b21KJnATF7QS7o5ziIf","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L8",null,{"children":"$L9"}],["$","div",null,{"hidden":true,"children":["$","$La",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lb"}]}]}],null]}],false]],"m":"$undefined","G":["$c","$undefined"],"S":true}
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]
d:I[27201,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"IconMark"]
7:null

File diff suppressed because one or more lines are too long

View file

@ -3,15 +3,15 @@
3:I[39756,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ClientPageRoot"]
6:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/12e7daed7311216f.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
6:I[31713,["/t2-mapper/_next/static/chunks/e6da73430a674f20.js","/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","/t2-mapper/_next/static/chunks/ad5913f83864409c.js","/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"OutletBoundary"]
a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/4fd93823156e59e8.js"],"MetadataBoundary"]
10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","style"]
:HL["/t2-mapper/_next/static/chunks/17606cb20096103a.css","style"]
:HL["/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","style"]
0:{"P":null,"b":"TA1NEd7uhnyFsTRk4wMjb","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/f0f828674b39f3d8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/781bfa3c9aab0c18.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/12e7daed7311216f.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10",[]],"S":true}
0:{"P":null,"b":"A7b21KJnATF7QS7o5ziIf","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/17606cb20096103a.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/e6da73430a674f20.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"defaultOptions":{"clearOnDefault":false},"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/afff663ba7029ccf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/88e3d9a60c48713e.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/93b588fa7f31935c.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/22ebafda1e5f0224.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/39f1afbfab5559a9.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/ad5913f83864409c.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/bb0aa1c978feffed.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10",[]],"S":true}
7:{}
8:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]]

View file

@ -0,0 +1,166 @@
import { Component, Suspense } from "react";
import type { ErrorInfo, MutableRefObject, ReactNode } from "react";
import { entityTypeColor } from "../demo/demoPlaybackUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { DemoPlayerModel } from "./DemoPlayerModel";
import { DemoShapeModel, DemoWeaponModel } from "./DemoShapeModel";
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
import { PlayerNameplate } from "./PlayerNameplate";
import { useEngineStoreApi } from "../state";
import type { DemoEntity } from "../demo/types";
/**
* Renders a non-camera demo entity.
* The group name must match the entity ID so the AnimationMixer can target it.
* Player entities use DemoPlayerModel for skeletal animation; others use
* DemoShapeModel.
*/
export function DemoEntityGroup({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const name = String(entity.id);
if (entity.visual?.kind === "tracer") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "tracer" }}>
<Suspense fallback={null}>
<DemoTracerProjectile entity={entity} visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (entity.visual?.kind === "sprite") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "sprite" }}>
<Suspense fallback={null}>
<DemoSpriteProjectile visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (!entity.dataBlock) {
return (
<group name={name}>
<group name="model">
<mesh>
<sphereGeometry args={[0.3, 6, 4]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
const fallback = (
<mesh>
<sphereGeometry args={[0.5, 8, 6]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
);
// Player entities use skeleton-preserving DemoPlayerModel for animation.
if (entity.type === "Player") {
const isControlPlayer =
entity.id ===
engineStore.getState().playback.recording?.controlPlayerGhostId;
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoPlayerModel entity={entity} timeRef={timeRef} />
</Suspense>
</ShapeErrorBoundary>
{!isControlPlayer && (
<Suspense fallback={null}>
<PlayerNameplate entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group>
</group>
);
}
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoShapeModel shapeName={entity.dataBlock} entityId={entity.id} />
</Suspense>
</ShapeErrorBoundary>
</group>
{entity.weaponShape && (
<group name="weapon">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<DemoWeaponModel
shapeName={entity.weaponShape}
playerShapeName={entity.dataBlock}
/>
</Suspense>
</ShapeErrorBoundary>
</group>
)}
</group>
);
}
export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) {
const id = String(entity.id);
const bits: string[] = [];
bits.push(`${id} (${entity.type})`);
if (entity.className) bits.push(`class ${entity.className}`);
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
bits.push(
entity.shapeHint
? `shapeHint ${entity.shapeHint}`
: "shapeHint <none resolved>",
);
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
}
/** Error boundary that renders a fallback when shape loading fails. */
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn(
"[demo] Shape load failed:",
error.message,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,549 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import {
Group,
Quaternion,
Raycaster,
Vector3,
} from "three";
import {
buildStreamDemoEntity,
CAMERA_COLLISION_RADIUS,
DEFAULT_EYE_HEIGHT,
hasAncestorNamed,
nextLifecycleInstanceId,
streamSnapshotSignature,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../demo/demoPlaybackUtils";
import { shapeToUrl } from "../loaders";
import { TickProvider } from "./TickProvider";
import { DemoEntityGroup } from "./DemoEntities";
import { PlayerEyeOffset } from "./DemoPlayerModel";
import { useEngineStoreApi } from "../state";
import type { DemoEntity, DemoRecording, DemoStreamSnapshot } from "../demo/types";
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
const _hitNormal = new Vector3();
const _orbitRaycaster = new Raycaster();
let streamingDemoPlaybackMountCount = 0;
let streamingDemoPlaybackUnmountCount = 0;
export function StreamingDemoPlayback({ recording }: { recording: DemoRecording }) {
const engineStore = useEngineStoreApi();
const instanceIdRef = useRef<string | null>(null);
if (!instanceIdRef.current) {
instanceIdRef.current = nextLifecycleInstanceId("StreamingDemoPlayback");
}
const rootRef = useRef<Group>(null);
const timeRef = useRef(0);
const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef(recording.streamingPlayback ?? null);
const publishedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const entitySignatureRef = useRef("");
const entityMapRef = useRef<Map<string, DemoEntity>>(new Map());
const lastEntityRebuildEventMsRef = useRef(0);
const exhaustedEventLoggedRef = useRef(false);
const [entities, setEntities] = useState<DemoEntity[]>([]);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
useEffect(() => {
streamingDemoPlaybackMountCount += 1;
const mountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback mounted",
meta: {
component: "StreamingDemoPlayback",
phase: "mount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
recordingDurationSec: Number(recording.duration.toFixed(3)),
ts: mountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback mounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
mountedAt,
});
return () => {
streamingDemoPlaybackUnmountCount += 1;
const unmountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback unmounted",
meta: {
component: "StreamingDemoPlayback",
phase: "unmount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
ts: unmountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback unmounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
unmountedAt,
});
};
}, [engineStore]);
const syncRenderableEntities = useCallback((snapshot: DemoStreamSnapshot) => {
const previousEntityCount = entityMapRef.current.size;
const nextSignature = streamSnapshotSignature(snapshot);
const shouldRebuild = entitySignatureRef.current !== nextSignature;
const nextMap = new Map<string, DemoEntity>();
for (const entity of snapshot.entities) {
let renderEntity = entityMapRef.current.get(entity.id);
if (
!renderEntity ||
renderEntity.type !== entity.type ||
renderEntity.dataBlock !== entity.dataBlock ||
renderEntity.weaponShape !== entity.weaponShape ||
renderEntity.className !== entity.className ||
renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint
) {
renderEntity = buildStreamDemoEntity(
entity.id,
entity.type,
entity.dataBlock,
entity.visual,
entity.direction,
entity.weaponShape,
entity.playerName,
entity.className,
entity.ghostIndex,
entity.dataBlockId,
entity.shapeHint,
);
}
renderEntity.playerName = entity.playerName;
renderEntity.iffColor = entity.iffColor;
renderEntity.dataBlock = entity.dataBlock;
renderEntity.visual = entity.visual;
renderEntity.direction = entity.direction;
renderEntity.weaponShape = entity.weaponShape;
renderEntity.className = entity.className;
renderEntity.ghostIndex = entity.ghostIndex;
renderEntity.dataBlockId = entity.dataBlockId;
renderEntity.shapeHint = entity.shapeHint;
if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({
time: snapshot.timeSec,
position: entity.position ?? [0, 0, 0],
rotation: entity.rotation ?? [0, 0, 0, 1],
});
}
const kf = renderEntity.keyframes[0];
kf.time = snapshot.timeSec;
if (entity.position) kf.position = entity.position;
if (entity.rotation) kf.rotation = entity.rotation;
kf.velocity = entity.velocity;
kf.health = entity.health;
kf.energy = entity.energy;
kf.actionAnim = entity.actionAnim;
kf.actionAtEnd = entity.actionAtEnd;
kf.damageState = entity.damageState;
nextMap.set(entity.id, renderEntity);
}
entityMapRef.current = nextMap;
if (shouldRebuild) {
entitySignatureRef.current = nextSignature;
setEntities(Array.from(nextMap.values()));
const now = Date.now();
if (now - lastEntityRebuildEventMsRef.current >= 500) {
lastEntityRebuildEventMsRef.current = now;
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "stream.entities.rebuild",
message: "Renderable demo entity list was rebuilt",
meta: {
previousEntityCount,
nextEntityCount: nextMap.size,
snapshotTimeSec: Number(snapshot.timeSec.toFixed(3)),
},
});
}
}
let nextFirstPersonShape: string | null = null;
if (snapshot.camera?.mode === "first-person" && snapshot.camera.controlEntityId) {
const entity = nextMap.get(snapshot.camera.controlEntityId);
if (entity?.dataBlock) {
nextFirstPersonShape = entity.dataBlock;
}
}
setFirstPersonShape((prev) =>
prev === nextFirstPersonShape ? prev : nextFirstPersonShape,
);
}, [engineStore]);
useEffect(() => {
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
entitySignatureRef.current = "";
publishedSnapshotRef.current = null;
timeRef.current = 0;
playbackClockRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
exhaustedEventLoggedRef.current = false;
const stream = streamRef.current;
if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null);
return;
}
stream.reset();
// Preload weapon effect shapes (explosions) so they're cached before
// the first projectile detonates — otherwise the GLB fetch latency
// causes the short-lived explosion entity to expire before it renders.
for (const shape of stream.getEffectShapes()) {
useGLTF.preload(shapeToUrl(shape));
}
const snapshot = stream.getSnapshot();
timeRef.current = snapshot.timeSec;
playbackClockRef.current = snapshot.timeSec;
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
syncRenderableEntities(snapshot);
engineStore.getState().setPlaybackStreamSnapshot(snapshot);
publishedSnapshotRef.current = snapshot;
return () => {
engineStore.getState().setPlaybackStreamSnapshot(null);
};
}, [recording, engineStore, syncRenderableEntities]);
useFrame((state, delta) => {
const stream = streamRef.current;
if (!stream) return;
const storeState = engineStore.getState();
const playback = storeState.playback;
const isPlaying = playback.status === "playing";
const requestedTimeSec = playback.timeMs / 1000;
const externalSeekWhilePaused =
!isPlaying && Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
const externalSeekWhilePlaying =
isPlaying && Math.abs(requestedTimeSec - timeRef.current) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
if (isSeeking) {
// Sync stream cursor to UI/programmatic seek.
playbackClockRef.current = requestedTimeSec;
}
if (isPlaying) {
playbackClockRef.current += delta * playback.rate;
}
const moveTicksNeeded = Math.max(
1,
Math.ceil((delta * 1000 * Math.max(playback.rate, 0.01)) / 32) + 2,
);
// Torque interpolates backwards from the end of the current 32ms tick.
// We sample one tick ahead and blend previous->current for smooth render.
const sampleTimeSec = playbackClockRef.current + STREAM_TICK_SEC;
// During a seek, process all ticks to the target immediately so the world
// state is fully reconstructed. The per-frame tick limit only applies
// during normal playback advancement.
const snapshot = stream.stepToTime(
sampleTimeSec,
isPlaying && !isSeeking ? moveTicksNeeded : Number.POSITIVE_INFINITY,
);
const currentTick = currentTickSnapshotRef.current;
if (
!currentTick ||
snapshot.timeSec < currentTick.timeSec ||
snapshot.timeSec - currentTick.timeSec > STREAM_TICK_SEC * 1.5
) {
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
} else if (snapshot.timeSec !== currentTick.timeSec) {
prevTickSnapshotRef.current = currentTick;
currentTickSnapshotRef.current = snapshot;
}
const renderCurrent = currentTickSnapshotRef.current ?? snapshot;
const renderPrev = prevTickSnapshotRef.current ?? renderCurrent;
const tickStartTime = renderCurrent.timeSec - STREAM_TICK_SEC;
const interpT = Math.max(
0,
Math.min(1, (playbackClockRef.current - tickStartTime) / STREAM_TICK_SEC),
);
timeRef.current = playbackClockRef.current;
if (snapshot.exhausted && isPlaying) {
playbackClockRef.current = Math.min(playbackClockRef.current, snapshot.timeSec);
}
syncRenderableEntities(renderCurrent);
const publishedSnapshot = publishedSnapshotRef.current;
const shouldPublish =
!publishedSnapshot ||
renderCurrent.timeSec !== publishedSnapshot.timeSec ||
renderCurrent.exhausted !== publishedSnapshot.exhausted ||
renderCurrent.status.health !== publishedSnapshot.status.health ||
renderCurrent.status.energy !== publishedSnapshot.status.energy ||
renderCurrent.camera?.mode !== publishedSnapshot.camera?.mode ||
renderCurrent.camera?.controlEntityId !==
publishedSnapshot.camera?.controlEntityId ||
renderCurrent.camera?.orbitTargetId !==
publishedSnapshot.camera?.orbitTargetId;
if (shouldPublish) {
publishedSnapshotRef.current = renderCurrent;
storeState.setPlaybackStreamSnapshot(renderCurrent);
}
const currentCamera = renderCurrent.camera;
const previousCamera =
currentCamera &&
renderPrev.camera &&
renderPrev.camera.mode === currentCamera.mode &&
renderPrev.camera.controlEntityId === currentCamera.controlEntityId &&
renderPrev.camera.orbitTargetId === currentCamera.orbitTargetId
? renderPrev.camera
: null;
if (currentCamera) {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
const pz = previousCamera.position[2];
const cx = currentCamera.position[0];
const cy = currentCamera.position[1];
const cz = currentCamera.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
state.camera.position.set(iy, iz, ix);
_interpQuatA.set(...previousCamera.rotation);
_interpQuatB.set(...currentCamera.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
state.camera.quaternion.copy(_interpQuatA);
} else {
state.camera.position.set(
currentCamera.position[1],
currentCamera.position[2],
currentCamera.position[0],
);
state.camera.quaternion.set(...currentCamera.rotation);
}
if (
Number.isFinite(currentCamera.fov) &&
"isPerspectiveCamera" in state.camera &&
(state.camera as any).isPerspectiveCamera
) {
const perspectiveCamera = state.camera as any;
const fovValue =
previousCamera && Number.isFinite(previousCamera.fov)
? previousCamera.fov + (currentCamera.fov - previousCamera.fov) * interpT
: currentCamera.fov;
const verticalFov = torqueHorizontalFovToThreeVerticalFov(
fovValue,
perspectiveCamera.aspect,
);
if (Math.abs(perspectiveCamera.fov - verticalFov) > 0.01) {
perspectiveCamera.fov = verticalFov;
perspectiveCamera.updateProjectionMatrix();
}
}
}
const currentEntities = new Map(renderCurrent.entities.map((e) => [e.id, e]));
const previousEntities = new Map(renderPrev.entities.map((e) => [e.id, e]));
const root = rootRef.current;
if (root) {
for (const child of root.children) {
const entity = currentEntities.get(child.name);
if (!entity?.position) {
child.visible = false;
continue;
}
child.visible = true;
const previousEntity = previousEntities.get(child.name);
if (previousEntity?.position) {
const px = previousEntity.position[0];
const py = previousEntity.position[1];
const pz = previousEntity.position[2];
const cx = entity.position[0];
const cy = entity.position[1];
const cz = entity.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
child.position.set(iy, iz, ix);
} else {
child.position.set(entity.position[1], entity.position[2], entity.position[0]);
}
if (entity.faceViewer) {
child.quaternion.copy(state.camera.quaternion);
} else if (entity.visual?.kind === "tracer") {
child.quaternion.identity();
} else if (entity.rotation) {
if (previousEntity?.rotation) {
_interpQuatA.set(...previousEntity.rotation);
_interpQuatB.set(...entity.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
child.quaternion.copy(_interpQuatA);
} else {
child.quaternion.set(...entity.rotation);
}
}
}
}
const mode = currentCamera?.mode;
if (mode === "third-person" && root && currentCamera?.orbitTargetId) {
const targetGroup = root.children.find(
(child) => child.name === currentCamera.orbitTargetId,
);
if (targetGroup) {
const orbitEntity = currentEntities.get(currentCamera.orbitTargetId);
_orbitTarget.copy(targetGroup.position);
// Torque orbits the target's render world-box center; player positions
// in our stream are feet-level, so lift to an approximate center.
if (orbitEntity?.type === "Player") {
_orbitTarget.y += 1.0;
}
let hasDirection = false;
if (
typeof currentCamera.yaw === "number" &&
typeof currentCamera.pitch === "number"
) {
const sx = Math.sin(currentCamera.pitch);
const cx = Math.cos(currentCamera.pitch);
const sz = Math.sin(currentCamera.yaw);
const cz = Math.cos(currentCamera.yaw);
// Camera::validateEyePoint uses Camera::setPosition's column1 in
// Torque space as the orbit pull-back direction. Converted to Three,
// that target->camera vector is (-cx, -sz*sx, -cz*sx).
_orbitDir.set(-cx, -sz * sx, -cz * sx);
hasDirection = _orbitDir.lengthSq() > 1e-8;
}
if (!hasDirection) {
_orbitDir.copy(state.camera.position).sub(_orbitTarget);
hasDirection = _orbitDir.lengthSq() > 1e-8;
}
if (hasDirection) {
_orbitDir.normalize();
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
_orbitCandidate.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance);
// Mirror Camera::validateEyePoint: cast 2.5x desired distance toward
// the candidate and pull in if an obstacle blocks the orbit.
_orbitRaycaster.near = 0.001;
_orbitRaycaster.far = orbitDistance * 2.5;
_orbitRaycaster.camera = state.camera;
_orbitRaycaster.set(_orbitTarget, _orbitDir);
const hits = _orbitRaycaster.intersectObjects(state.scene.children, true);
for (const hit of hits) {
if (hit.distance <= 0.0001) continue;
if (hasAncestorNamed(hit.object, currentCamera.orbitTargetId)) continue;
if (!hit.face) break;
_hitNormal.copy(hit.face.normal).transformDirection(hit.object.matrixWorld);
const dot = -_orbitDir.dot(_hitNormal);
if (dot > 0.01) {
let colDist = hit.distance - CAMERA_COLLISION_RADIUS / dot;
if (colDist > orbitDistance) colDist = orbitDistance;
if (colDist < 0) colDist = 0;
_orbitCandidate
.copy(_orbitTarget)
.addScaledVector(_orbitDir, colDist);
}
break;
}
state.camera.position.copy(_orbitCandidate);
state.camera.lookAt(_orbitTarget);
}
}
}
if (mode === "first-person" && root && currentCamera?.controlEntityId) {
const playerGroup = root.children.find(
(child) => child.name === currentCamera.controlEntityId,
);
if (playerGroup) {
_tmpVec.copy(eyeOffsetRef.current).applyQuaternion(playerGroup.quaternion);
state.camera.position.add(_tmpVec);
} else {
state.camera.position.y += eyeOffsetRef.current.y;
}
}
if (isPlaying && snapshot.exhausted) {
if (!exhaustedEventLoggedRef.current) {
exhaustedEventLoggedRef.current = true;
storeState.recordPlaybackDiagnosticEvent({
kind: "stream.exhausted",
message: "Streaming playback reached end-of-stream while playing",
meta: {
streamTimeSec: Number(snapshot.timeSec.toFixed(3)),
requestedPlaybackSec: Number(playbackClockRef.current.toFixed(3)),
},
});
}
storeState.setPlaybackStatus("paused");
} else if (!snapshot.exhausted) {
exhaustedEventLoggedRef.current = false;
}
const timeMs = playbackClockRef.current * 1000;
if (Math.abs(timeMs - playback.timeMs) > 0.5) {
storeState.setPlaybackTime(timeMs);
}
});
return (
<TickProvider>
<group ref={rootRef}>
{entities.map((entity) => (
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} />
))}
</group>
{firstPersonShape && (
<Suspense fallback={null}>
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} />
</Suspense>
)}
</TickProvider>
);
}

View file

@ -0,0 +1,263 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
AnimationMixer,
Group,
LoopOnce,
LoopRepeat,
Object3D,
Vector3,
} from "three";
import type { AnimationAction } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
DEFAULT_EYE_HEIGHT,
getKeyframeAtTime,
getPosedNodeTransform,
processShapeScene,
} from "../demo/demoPlaybackUtils";
import { pickMoveAnimation } from "../demo/playerAnimation";
import { useStaticShape } from "./GenericShape";
import { ShapeErrorBoundary } from "./DemoEntities";
import { useEngineStoreApi } from "../state";
import type { DemoEntity } from "../demo/types";
/**
* Renders a player model with skeleton-preserving animation.
*
* Uses SkeletonUtils.clone to deep-clone the GLTF scene with skeleton bindings
* intact, then drives a per-entity AnimationMixer to play movement animations
* (Root, Forward, Back, Side, Fall) selected from the keyframe velocity data.
* Weapon is attached to the animated Mount0 bone.
*/
export function DemoPlayerModel({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const gltf = useStaticShape(entity.dataBlock!);
// Clone scene preserving skeleton bindings, create mixer, find Mount0 bone.
const { clonedScene, mixer, mount0 } = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
processShapeScene(scene);
const mix = new AnimationMixer(scene);
let m0: Object3D | null = null;
scene.traverse((n) => {
if (!m0 && n.name === "Mount0") m0 = n;
});
return { clonedScene: scene, mixer: mix, mount0: m0 };
}, [gltf]);
// Build case-insensitive clip lookup and start with Root animation.
const animActionsRef = useRef(new Map<string, AnimationAction>());
const currentAnimRef = useRef({ name: "Root", timeScale: 1 });
const isDeadRef = useRef(false);
useEffect(() => {
const actions = new Map<string, AnimationAction>();
for (const clip of gltf.animations) {
const action = mixer.clipAction(clip);
actions.set(clip.name.toLowerCase(), action);
}
animActionsRef.current = actions;
// Start with Root (idle) animation.
const rootAction = actions.get("root");
if (rootAction) {
rootAction.play();
}
currentAnimRef.current = { name: "Root", timeScale: 1 };
// Force initial pose evaluation.
mixer.update(0);
return () => {
mixer.stopAllAction();
animActionsRef.current = new Map();
};
}, [mixer, gltf.animations]);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
const time = timeRef.current;
// Resolve velocity at current playback time.
const kf = getKeyframeAtTime(entity.keyframes, time);
const isDead = kf?.damageState != null && kf.damageState >= 1;
const actions = animActionsRef.current;
// Alive→Dead transition: play a random death animation.
if (isDead && !isDeadRef.current) {
isDeadRef.current = true;
const deathClips = [...actions.keys()].filter((k) =>
k.startsWith("die"),
);
if (deathClips.length > 0) {
const pick = deathClips[Math.floor(Math.random() * deathClips.length)];
const prevAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
const deathAction = actions.get(pick)!;
deathAction.setLoop(LoopOnce, 1);
deathAction.clampWhenFinished = true;
deathAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
currentAnimRef.current = { name: pick, timeScale: 1 };
}
}
// Dead→Alive transition: stop death animation, let movement resume.
if (!isDead && isDeadRef.current) {
isDeadRef.current = false;
const deathAction = actions.get(currentAnimRef.current.name.toLowerCase());
if (deathAction) {
deathAction.stop();
deathAction.setLoop(LoopRepeat, Infinity);
deathAction.clampWhenFinished = false;
}
// Reset to root so movement selection picks up on next iteration.
currentAnimRef.current = { name: "Root", timeScale: 1 };
const rootAction = actions.get("root");
if (rootAction) rootAction.reset().play();
}
// Movement animation selection (skip while dead).
if (!isDeadRef.current) {
const anim = pickMoveAnimation(
kf?.velocity,
kf?.rotation ?? [0, 0, 0, 1],
);
const prev = currentAnimRef.current;
if (anim.animation !== prev.name || anim.timeScale !== prev.timeScale) {
const prevAction = actions.get(prev.name.toLowerCase());
const nextAction = actions.get(anim.animation.toLowerCase());
if (nextAction) {
if (isPlaying && prevAction && prevAction !== nextAction) {
prevAction.fadeOut(ANIM_TRANSITION_TIME);
nextAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
} else {
if (prevAction && prevAction !== nextAction) prevAction.stop();
nextAction.reset().play();
}
nextAction.timeScale = anim.timeScale;
currentAnimRef.current = {
name: anim.animation,
timeScale: anim.timeScale,
};
}
}
}
// Advance or evaluate the body animation mixer.
if (isPlaying) {
mixer.update(delta * playback.rate);
} else {
mixer.update(0);
}
});
return (
<>
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
</group>
{entity.weaponShape && mount0 && (
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<AnimatedWeaponMount
weaponShape={entity.weaponShape}
mount0={mount0}
/>
</Suspense>
</ShapeErrorBoundary>
)}
</>
);
}
/**
* Imperatively attaches a weapon model to the animated Mount0 bone.
* Computes the Mountpoint inverse offset so the weapon's grip aligns with
* the player's hand. The weapon follows the animated skeleton automatically.
*/
export function AnimatedWeaponMount({
weaponShape,
mount0,
}: {
weaponShape: string;
mount0: Object3D;
}) {
const weaponGltf = useStaticShape(weaponShape);
useEffect(() => {
const weaponClone = weaponGltf.scene.clone(true);
processShapeScene(weaponClone);
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
if (mp) {
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
weaponClone.position.copy(invPos);
weaponClone.quaternion.copy(invQuat);
}
mount0.add(weaponClone);
return () => {
mount0.remove(weaponClone);
};
}, [weaponGltf, mount0]);
return null;
}
/**
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton
* hierarchy. Its world Y in GLB Y-up space gives the height above the player's
* feet, which we use as the first-person camera offset.
*/
export function PlayerEyeOffset({
shapeName,
eyeOffsetRef,
}: {
shapeName: string;
eyeOffsetRef: MutableRefObject<Vector3>;
}) {
const gltf = useStaticShape(shapeName);
useEffect(() => {
// Get Eye node position from the posed (Root animation) skeleton.
const eye = getPosedNodeTransform(gltf.scene, gltf.animations, "Eye");
if (eye) {
// Convert from GLB space to entity space via ShapeRenderer's R90:
// R90 maps GLB (x,y,z) → entity (z, y, -x).
// This gives ~(0.169, 2.122, 0.0) — 17cm forward and 2.12m up.
eyeOffsetRef.current.set(eye.position.z, eye.position.y, -eye.position.x);
} else {
eyeOffsetRef.current.set(0, DEFAULT_EYE_HEIGHT, 0);
}
}, [gltf, eyeOffsetRef]);
return null;
}

View file

@ -0,0 +1,226 @@
import { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import {
AdditiveBlending,
Color,
DoubleSide,
Quaternion,
SRGBColorSpace,
Vector3,
} from "three";
import type { BufferAttribute, Mesh } from "three";
import {
setupEffectTexture,
torqueVecToThree,
setQuaternionFromDir,
} from "../demo/demoPlaybackUtils";
import { textureToUrl } from "../loaders";
import type { DemoEntity, DemoTracerVisual, DemoSpriteVisual } from "../demo/types";
const _tracerDir = new Vector3();
const _tracerDirFromCam = new Vector3();
const _tracerCross = new Vector3();
const _tracerStart = new Vector3();
const _tracerEnd = new Vector3();
const _tracerWorldPos = new Vector3();
const _upY = new Vector3(0, 1, 0);
export function DemoSpriteProjectile({ visual }: { visual: DemoSpriteVisual }) {
const url = textureToUrl(visual.texture);
const texture = useTexture(url, (tex) => {
const t = Array.isArray(tex) ? tex[0] : tex;
setupEffectTexture(t);
});
const map = Array.isArray(texture) ? texture[0] : texture;
// Convert sRGB datablock color to linear for Three.js material.
const color = useMemo(
() =>
new Color().setRGB(visual.color.r, visual.color.g, visual.color.b, SRGBColorSpace),
[visual.color.r, visual.color.g, visual.color.b],
);
return (
<sprite scale={[visual.size, visual.size, 1]}>
<spriteMaterial
map={map}
color={color}
transparent
blending={AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</sprite>
);
}
export function DemoTracerProjectile({
entity,
visual,
}: {
entity: DemoEntity;
visual: DemoTracerVisual;
}) {
const tracerRef = useRef<Mesh>(null);
const tracerPosRef = useRef<BufferAttribute>(null);
const crossRef = useRef<Mesh>(null);
const orientQuatRef = useRef(new Quaternion());
const tracerUrls = useMemo(
() => [
textureToUrl(visual.texture),
textureToUrl(visual.crossTexture ?? visual.texture),
],
[visual.texture, visual.crossTexture],
);
const textures = useTexture(tracerUrls, (loaded) => {
const list = Array.isArray(loaded) ? loaded : [loaded];
for (const tex of list) {
setupEffectTexture(tex);
}
});
const [tracerTexture, crossTexture] = Array.isArray(textures)
? textures
: [textures, textures];
useFrame(({ camera }) => {
const tracerMesh = tracerRef.current;
const posAttr = tracerPosRef.current;
if (!tracerMesh || !posAttr) return;
const kf = entity.keyframes[0];
const pos = kf?.position;
const direction = entity.direction ?? kf?.velocity;
if (!pos || !direction) {
tracerMesh.visible = false;
if (crossRef.current) crossRef.current.visible = false;
return;
}
torqueVecToThree(direction, _tracerDir);
if (_tracerDir.lengthSq() < 1e-8) {
tracerMesh.visible = false;
if (crossRef.current) crossRef.current.visible = false;
return;
}
_tracerDir.normalize();
tracerMesh.visible = true;
torqueVecToThree(pos, _tracerWorldPos);
_tracerDirFromCam.copy(_tracerWorldPos).sub(camera.position);
_tracerCross.crossVectors(_tracerDirFromCam, _tracerDir);
if (_tracerCross.lengthSq() < 1e-8) {
_tracerCross.crossVectors(_upY, _tracerDir);
if (_tracerCross.lengthSq() < 1e-8) {
_tracerCross.set(1, 0, 0);
}
}
_tracerCross.normalize().multiplyScalar(visual.tracerWidth);
const halfLength = visual.tracerLength * 0.5;
_tracerStart.copy(_tracerDir).multiplyScalar(-halfLength);
_tracerEnd.copy(_tracerDir).multiplyScalar(halfLength);
const posArray = posAttr.array as Float32Array;
posArray[0] = _tracerStart.x + _tracerCross.x;
posArray[1] = _tracerStart.y + _tracerCross.y;
posArray[2] = _tracerStart.z + _tracerCross.z;
posArray[3] = _tracerStart.x - _tracerCross.x;
posArray[4] = _tracerStart.y - _tracerCross.y;
posArray[5] = _tracerStart.z - _tracerCross.z;
posArray[6] = _tracerEnd.x - _tracerCross.x;
posArray[7] = _tracerEnd.y - _tracerCross.y;
posArray[8] = _tracerEnd.z - _tracerCross.z;
posArray[9] = _tracerEnd.x + _tracerCross.x;
posArray[10] = _tracerEnd.y + _tracerCross.y;
posArray[11] = _tracerEnd.z + _tracerCross.z;
posAttr.needsUpdate = true;
const crossMesh = crossRef.current;
if (!crossMesh) return;
if (!visual.renderCross) {
crossMesh.visible = false;
return;
}
_tracerDirFromCam.normalize();
const angle = _tracerDir.dot(_tracerDirFromCam);
if (angle > -visual.crossViewAng && angle < visual.crossViewAng) {
crossMesh.visible = false;
return;
}
crossMesh.visible = true;
setQuaternionFromDir(_tracerDir, orientQuatRef.current);
crossMesh.quaternion.copy(orientQuatRef.current);
crossMesh.scale.setScalar(visual.crossSize);
});
return (
<>
<mesh ref={tracerRef}>
<bufferGeometry>
<bufferAttribute
ref={tracerPosRef}
attach="attributes-position"
args={[new Float32Array(12), 3]}
/>
<bufferAttribute
attach="attributes-uv"
args={[
new Float32Array([
0, 0, 0, 1, 1, 1, 1, 0,
]),
2,
]}
/>
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry>
<meshBasicMaterial
map={tracerTexture}
transparent
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
toneMapped={false}
/>
</mesh>
{visual.renderCross ? (
<mesh ref={crossRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[
new Float32Array([
-0.5, 0, -0.5,
0.5, 0, -0.5,
0.5, 0, 0.5,
-0.5, 0, 0.5,
]),
3,
]}
/>
<bufferAttribute
attach="attributes-uv"
args={[
new Float32Array([
0, 0, 0, 1, 1, 1, 1, 0,
]),
2,
]}
/>
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry>
<meshBasicMaterial
map={crossTexture}
transparent
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
toneMapped={false}
/>
</mesh>
) : null}
</>
);
}

View file

@ -0,0 +1,125 @@
import { useMemo } from "react";
import { Quaternion, Vector3 } from "three";
import {
_r90,
_r90inv,
getPosedNodeTransform,
} from "../demo/demoPlaybackUtils";
import {
ShapeRenderer,
useStaticShape,
} from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
/** Renders a shape model for a demo entity using the existing shape pipeline. */
export function DemoShapeModel({
shapeName,
entityId,
}: {
shapeName: string;
entityId: number | string;
}) {
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "player",
_className: "Player",
_id: typeof entityId === "number" ? entityId : 0,
}),
[entityId],
);
return (
<ShapeInfoProvider
object={torqueObject}
shapeName={shapeName}
type="StaticShape"
>
<ShapeRenderer loadingColor="#00ff88" />
</ShapeInfoProvider>
);
}
/**
* Renders a mounted weapon using the Torque engine's mount system.
*
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
* animation) pose. The mount transform is conjugated by ShapeRenderer's 90° Y
* rotation: T_mount = R90 * M0 * MP^(-1) * R90^(-1).
*/
export function DemoWeaponModel({
shapeName,
playerShapeName,
}: {
shapeName: string;
playerShapeName: string;
}) {
const playerGltf = useStaticShape(playerShapeName);
const weaponGltf = useStaticShape(shapeName);
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed (Root animation) skeleton.
const m0 = getPosedNodeTransform(
playerGltf.scene,
playerGltf.animations,
"Mount0",
);
if (!m0) return { position: undefined, quaternion: undefined };
// Get Mountpoint from weapon (may not be animated).
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
// Compute T_mount = R90 * M0 * MP^(-1) * R90^(-1)
// This conjugates the GLB-space mount transform by ShapeRenderer's 90° Y
// rotation so the weapon is correctly oriented in entity space.
let combinedPos: Vector3;
let combinedQuat: Quaternion;
if (mp) {
// MP^(-1)
const mpInvQuat = mp.quaternion.clone().invert();
const mpInvPos = mp.position.clone().negate().applyQuaternion(mpInvQuat);
// M0 * MP^(-1)
combinedQuat = m0.quaternion.clone().multiply(mpInvQuat);
combinedPos = mpInvPos
.clone()
.applyQuaternion(m0.quaternion)
.add(m0.position);
} else {
combinedPos = m0.position.clone();
combinedQuat = m0.quaternion.clone();
}
// R90 * combined * R90^(-1)
const mountPos = combinedPos.applyQuaternion(_r90);
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
return { position: mountPos, quaternion: mountQuat };
}, [playerGltf, weaponGltf]);
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "weapon",
_className: "Weapon",
_id: 0,
}),
[],
);
return (
<ShapeInfoProvider object={torqueObject} shapeName={shapeName} type="Item">
<group
position={mountTransform.position}
quaternion={mountTransform.quaternion}
>
<ShapeRenderer loadingColor="#4488ff" />
</group>
</ShapeInfoProvider>
);
}

View file

@ -1,10 +1,22 @@
import { memo, ReactNode, useEffect, useRef, useState } from "react";
import { Object3D } from "three";
import { useDistanceFromCamera } from "./useDistanceFromCamera";
import { memo, ReactNode, useRef, useState } from "react";
import { Object3D, Vector3 } from "three";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
const DEFAULT_POSITION = [0, 0, 0] as [x: number, y: number, z: number];
const _worldPos = new Vector3();
/** Check if a world position is behind the camera using only scalar math. */
function isBehindCamera(
camera: { matrixWorld: { elements: number[] } },
wx: number,
wy: number,
wz: number,
): boolean {
const e = camera.matrixWorld.elements;
// Dot product of (objectPos - cameraPos) with camera forward (-Z column).
return (wx - e[12]) * -e[8] + (wy - e[13]) * -e[9] + (wz - e[14]) * -e[10] < 0;
}
export const FloatingLabel = memo(function FloatingLabel({
children,
@ -19,39 +31,36 @@ export const FloatingLabel = memo(function FloatingLabel({
}) {
const fadeWithDistance = opacity === "fadeWithDistance";
const groupRef = useRef<Object3D>(null);
const distanceRef = useDistanceFromCamera(groupRef);
const [isVisible, setIsVisible] = useState(opacity !== 0);
const labelRef = useRef<HTMLDivElement>(null);
// Initialize opacity when label ref is attached
useEffect(() => {
if (fadeWithDistance) {
if (labelRef.current && distanceRef.current != null) {
const opacity = Math.max(0, Math.min(1, 1 - distanceRef.current / 200));
labelRef.current.style.opacity = opacity.toString();
}
}
}, [isVisible, fadeWithDistance, distanceRef]);
useFrame(({ camera }) => {
const group = groupRef.current;
if (!group) return;
useFrame(() => {
if (fadeWithDistance) {
const distance = distanceRef.current;
const shouldBeVisible = distance != null && distance < 200;
group.getWorldPosition(_worldPos);
const behind = isBehindCamera(camera, _worldPos.x, _worldPos.y, _worldPos.z);
if (fadeWithDistance) {
const distance = behind ? Infinity : camera.position.distanceTo(_worldPos);
const shouldBeVisible = distance < 200;
// Update visibility state only when crossing threshold
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
// Update opacity directly on DOM element (no re-render)
// Update opacity directly on DOM element (no re-render).
if (labelRef.current && shouldBeVisible) {
const opacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = opacity.toString();
const fadeOpacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = fadeOpacity.toString();
}
} else {
setIsVisible(opacity !== 0);
const shouldBeVisible = !behind && opacity !== 0;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (labelRef.current) {
labelRef.current.style.opacity = opacity.toString();
labelRef.current.style.opacity = (opacity as number).toString();
}
}
});

View file

@ -0,0 +1,168 @@
import { useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Box3, Object3D, Vector3 } from "three";
import { getKeyframeAtTime } from "../demo/demoPlaybackUtils";
import { textureToUrl } from "../loaders";
import { useStaticShape } from "./GenericShape";
import type { DemoEntity } from "../demo/types";
/** Max distance at which nameplates are visible. */
const NAMEPLATE_FADE_DISTANCE = 150;
/** Padding above the shape's bounding box top for the IFF arrow. */
const IFF_PADDING = 0.1;
/** Height for the name + health label (slightly below the player's feet). */
const NAME_HEIGHT = -0.2;
const IFF_FRIENDLY_URL = textureToUrl("gui/hud_alliedtriangle");
const IFF_ENEMY_URL = textureToUrl("gui/hud_enemytriangle");
const _tmpVec = new Vector3();
/**
* Floating nameplate above a player model showing the entity name and a health
* bar. Fades out with distance.
*/
export function PlayerNameplate({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const gltf = useStaticShape(entity.dataBlock!);
const { camera } = useThree();
const groupRef = useRef<Object3D>(null);
const iffContainerRef = useRef<HTMLDivElement>(null);
const nameContainerRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const iffImgRef = useRef<HTMLImageElement>(null);
const [isVisible, setIsVisible] = useState(true);
const displayName = useMemo(() => {
if (entity.playerName) return entity.playerName;
if (typeof entity.id === "string") {
return entity.id.replace(/^player_/, "Player ");
}
return `Player ${entity.id}`;
}, [entity.id, entity.playerName]);
// Derive IFF height from the shape's bounding box.
const iffHeight = useMemo(() => {
const box = new Box3().setFromObject(gltf.scene);
return box.max.y + IFF_PADDING;
}, [gltf.scene]);
// Check whether this entity has any health data at all.
const hasHealthData = useMemo(
() => entity.keyframes.some((kf) => kf.health != null),
[entity.keyframes],
);
useFrame(() => {
const group = groupRef.current;
if (!group) return;
// Compute world-space distance to camera.
group.getWorldPosition(_tmpVec);
const distance = camera.position.distanceTo(_tmpVec);
// Check if behind camera using dot product with camera forward (-Z column).
const e = camera.matrixWorld.elements;
const behind =
(_tmpVec.x - e[12]) * -e[8] +
(_tmpVec.y - e[13]) * -e[9] +
(_tmpVec.z - e[14]) * -e[10] <
0;
const shouldBeVisible = !behind && distance < NAMEPLATE_FADE_DISTANCE;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (!shouldBeVisible) return;
// Hide nameplate when player is dead.
const kf = getKeyframeAtTime(entity.keyframes, timeRef.current);
const health = kf?.health ?? 1;
if (kf?.damageState != null && kf.damageState >= 1) {
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";
if (nameContainerRef.current)
nameContainerRef.current.style.opacity = "0";
return;
}
// Update opacity on both label containers.
const opacity = Math.max(
0,
Math.min(1, 1 - distance / NAMEPLATE_FADE_DISTANCE),
);
const opacityStr = opacity.toString();
if (iffContainerRef.current) {
iffContainerRef.current.style.opacity = opacityStr;
}
if (nameContainerRef.current) {
nameContainerRef.current.style.opacity = opacityStr;
}
// Update IFF arrow image imperatively — entity.iffColor is mutated in-place
// by streaming playback without triggering re-renders.
if (iffImgRef.current && entity.iffColor) {
const url =
entity.iffColor.r > entity.iffColor.g
? IFF_ENEMY_URL
: IFF_FRIENDLY_URL;
if (iffImgRef.current.src !== url) {
iffImgRef.current.src = url;
}
}
// Update health bar fill.
if (fillRef.current && hasHealthData) {
fillRef.current.style.width = `${Math.max(0, Math.min(100, health * 100))}%`;
fillRef.current.style.background = entity.iffColor
? `rgb(${entity.iffColor.r}, ${entity.iffColor.g}, ${entity.iffColor.b})`
: "";
}
});
const iffMarkerUrl =
entity.iffColor && entity.iffColor.r > entity.iffColor.g
? IFF_ENEMY_URL
: IFF_FRIENDLY_URL;
return (
<group ref={groupRef}>
{isVisible && (
<>
<Html position={[0, iffHeight, 0]} center>
<div ref={iffContainerRef} className="PlayerNameplate PlayerTop">
<img
ref={iffImgRef}
className="PlayerNameplate-iffArrow"
src={iffMarkerUrl}
alt=""
/>
</div>
</Html>
<Html position={[0, NAME_HEIGHT, 0]} center>
<div
ref={nameContainerRef}
className="PlayerNameplate PlayerBottom"
>
<div className="PlayerNameplate-name">{displayName}</div>
{hasHealthData && (
<div className="PlayerNameplate-healthBar">
<div ref={fillRef} className="PlayerNameplate-healthFill" />
</div>
)}
</div>
</Html>
</>
)}
</group>
);
}

View file

@ -1,43 +0,0 @@
import {
AnimationClip,
QuaternionKeyframeTrack,
VectorKeyframeTrack,
} from "three";
import type { DemoEntity } from "./types";
/**
* Build a Three.js AnimationClip from a DemoEntity's keyframes.
* Position is in Torque space [x,y,z] converted to Three.js [y,z,x].
* Rotation is already a Three.js quaternion [x,y,z,w].
*/
export function createEntityClip(entity: DemoEntity): AnimationClip {
const { keyframes } = entity;
const name = String(entity.id);
const times = new Float32Array(keyframes.length);
const positions = new Float32Array(keyframes.length * 3);
const quaternions = new Float32Array(keyframes.length * 4);
for (let i = 0; i < keyframes.length; i++) {
const kf = keyframes[i];
times[i] = kf.time;
// Torque [x,y,z] → Three.js [y,z,x]
positions[i * 3] = kf.position[1];
positions[i * 3 + 1] = kf.position[2];
positions[i * 3 + 2] = kf.position[0];
const [qx, qy, qz, qw] = kf.rotation;
quaternions[i * 4] = qx;
quaternions[i * 4 + 1] = qy;
quaternions[i * 4 + 2] = qz;
quaternions[i * 4 + 3] = qw;
}
const tracks = [
new VectorKeyframeTrack(`${name}.position`, times, positions),
new QuaternionKeyframeTrack(`${name}.quaternion`, times, quaternions),
];
return new AnimationClip(name, -1, tracks);
}

View file

@ -0,0 +1,416 @@
import {
AnimationClip,
AnimationMixer,
ClampToEdgeWrapping,
Group,
LinearFilter,
Matrix4,
MeshLambertMaterial,
NoColorSpace,
Object3D,
Quaternion,
TextureLoader,
Vector3,
} from "three";
import type {
BufferGeometry,
MeshStandardMaterial,
Texture,
} from "three";
import {
createMaterialFromFlags,
applyShapeShaderModifications,
} from "../components/GenericShape";
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
import { setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type {
DemoEntity,
DemoKeyframe,
DemoStreamSnapshot,
} from "./types";
/** Fallback eye height when the player model isn't loaded or has no Cam node. */
export const DEFAULT_EYE_HEIGHT = 2.1;
/** Torque's animation crossfade duration (seconds). */
export const ANIM_TRANSITION_TIME = 0.25;
export const STREAM_TICK_MS = 32;
export const STREAM_TICK_SEC = STREAM_TICK_MS / 1000;
export const CAMERA_COLLISION_RADIUS = 0.05;
// ── Temp vectors / quaternions (module-level to avoid per-frame alloc) ──
const _tracerOrientI = new Vector3();
const _tracerOrientK = new Vector3();
const _tracerOrientMat = new Matrix4();
const _upY = new Vector3(0, 1, 0);
/** ShapeRenderer's 90° Y rotation and its inverse, used for mount transforms. */
export const _r90 = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
Math.PI / 2,
);
export const _r90inv = _r90.clone().invert();
// ── Lifecycle tracking ──
let lifecycleInstanceIdSeed = 0;
export function nextLifecycleInstanceId(prefix: string): string {
lifecycleInstanceIdSeed += 1;
return `${prefix}-${lifecycleInstanceIdSeed}`;
}
// ── Pure functions ──
/**
* Torque/Tribes stores camera FOV as horizontal degrees, while Three.js
* PerspectiveCamera.fov expects vertical degrees.
*/
export function torqueHorizontalFovToThreeVerticalFov(
torqueFovDeg: number,
aspect: number,
): number {
const safeAspect = Number.isFinite(aspect) && aspect > 0.000001 ? aspect : 4 / 3;
const clampedFov = Math.max(0.01, Math.min(179.99, torqueFovDeg));
const hRad = (clampedFov * Math.PI) / 180;
const vRad = 2 * Math.atan(Math.tan(hRad / 2) / safeAspect);
return (vRad * 180) / Math.PI;
}
export function setupEffectTexture(tex: Texture): void {
tex.wrapS = ClampToEdgeWrapping;
tex.wrapT = ClampToEdgeWrapping;
tex.minFilter = LinearFilter;
tex.magFilter = LinearFilter;
tex.colorSpace = NoColorSpace;
tex.flipY = false;
tex.needsUpdate = true;
}
export function torqueVecToThree(
v: [number, number, number],
out: Vector3,
): Vector3 {
return out.set(v[1], v[2], v[0]);
}
export function setQuaternionFromDir(dir: Vector3, out: Quaternion): void {
// Equivalent to MathUtils::createOrientFromDir in Torque:
// column1 = direction, with Torque up-vector converted to Three up-vector.
_tracerOrientI.crossVectors(dir, _upY);
if (_tracerOrientI.lengthSq() < 1e-8) {
_tracerOrientI.set(-1, 0, 0);
}
_tracerOrientI.normalize();
_tracerOrientK.crossVectors(_tracerOrientI, dir).normalize();
_tracerOrientMat.set(
_tracerOrientI.x,
dir.x,
_tracerOrientK.x,
0,
_tracerOrientI.y,
dir.y,
_tracerOrientK.y,
0,
_tracerOrientI.z,
dir.z,
_tracerOrientK.z,
0,
0,
0,
0,
1,
);
out.setFromRotationMatrix(_tracerOrientMat);
}
/** Binary search for the keyframe at or before the given time. */
export function getKeyframeAtTime(
keyframes: DemoKeyframe[],
time: number,
): DemoKeyframe | null {
if (keyframes.length === 0) return null;
if (time <= keyframes[0].time) return keyframes[0];
if (time >= keyframes[keyframes.length - 1].time)
return keyframes[keyframes.length - 1];
let lo = 0;
let hi = keyframes.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (keyframes[mid].time <= time) lo = mid;
else hi = mid;
}
return keyframes[lo];
}
/**
* Clone a shape scene, apply the "Root" idle animation at t=0, and return the
* world-space transform of the named node. This evaluates the skeleton at its
* idle pose rather than using the collapsed bind pose.
*/
export function getPosedNodeTransform(
scene: Group,
animations: AnimationClip[],
nodeName: string,
): { position: Vector3; quaternion: Quaternion } | null {
const clone = scene.clone(true);
const rootClip = animations.find((a) => a.name === "Root");
if (rootClip) {
const mixer = new AnimationMixer(clone);
mixer.clipAction(rootClip).play();
mixer.setTime(0);
}
clone.updateMatrixWorld(true);
let position: Vector3 | null = null;
let quaternion: Quaternion | null = null;
clone.traverse((n) => {
if (!position && n.name === nodeName) {
position = new Vector3();
quaternion = new Quaternion();
n.getWorldPosition(position);
n.getWorldQuaternion(quaternion);
}
});
if (!position || !quaternion) return null;
return { position, quaternion };
}
/**
* Smooth vertex normals across co-located split vertices (same position, different
* UVs). Matches the technique used by ShapeModel for consistent lighting.
*/
export function smoothVertexNormals(geometry: BufferGeometry): void {
geometry.computeVertexNormals();
const posAttr = geometry.attributes.position;
const normAttr = geometry.attributes.normal;
if (!posAttr || !normAttr) return;
const positions = posAttr.array as Float32Array;
const normals = normAttr.array as Float32Array;
// Build map of position -> vertex indices at that position.
const positionMap = new Map<string, number[]>();
for (let i = 0; i < posAttr.count; i++) {
const key = `${positions[i * 3].toFixed(4)},${positions[i * 3 + 1].toFixed(4)},${positions[i * 3 + 2].toFixed(4)}`;
if (!positionMap.has(key)) {
positionMap.set(key, []);
}
positionMap.get(key)!.push(i);
}
// Average normals for vertices at the same position.
for (const indices of positionMap.values()) {
if (indices.length > 1) {
let nx = 0,
ny = 0,
nz = 0;
for (const idx of indices) {
nx += normals[idx * 3];
ny += normals[idx * 3 + 1];
nz += normals[idx * 3 + 2];
}
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
nx /= len;
ny /= len;
nz /= len;
}
for (const idx of indices) {
normals[idx * 3] = nx;
normals[idx * 3 + 1] = ny;
normals[idx * 3 + 2] = nz;
}
}
}
normAttr.needsUpdate = true;
}
const _textureLoader = new TextureLoader();
/**
* Replace a PBR MeshStandardMaterial with a diffuse-only Lambert/Basic material
* matching the Tribes 2 material pipeline. Textures are loaded asynchronously
* from URLs (GLB files don't embed texture data; they store a resource_path in
* material userData instead).
*/
export function replaceWithShapeMaterial(mat: MeshStandardMaterial, vis: number) {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
if (!resourcePath) {
// No texture path — plain Lambert fallback with fog/lighting shaders.
const fallback = new MeshLambertMaterial({
color: mat.color,
side: 2, // DoubleSide
reflectivity: 0,
});
applyShapeShaderModifications(fallback);
return fallback;
}
// Load texture asynchronously via Three.js TextureLoader. The returned
// Texture is empty initially and gets populated when the image arrives;
// Three.js re-renders automatically once loaded.
const url = textureToUrl(resourcePath);
const texture = _textureLoader.load(url);
setupTexture(texture);
const result = createMaterialFromFlags(mat, texture, flagNames, false, vis);
// createMaterialFromFlags may return a [back, front] pair for translucent
// materials. Use the front material since we can't split meshes imperatively.
if (Array.isArray(result)) {
return result[1];
}
return result;
}
/**
* Post-process a cloned shape scene: hide collision/hull geometry, smooth
* normals, and replace PBR materials with diffuse-only Lambert materials.
*/
export function processShapeScene(scene: Object3D): void {
// Find skeleton for hull bone filtering.
let skeleton: any = null;
scene.traverse((n: any) => {
if (!skeleton && n.skeleton) skeleton = n.skeleton;
});
const hullBoneIndices = skeleton
? getHullBoneIndices(skeleton)
: new Set<number>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
// Hide unwanted nodes: hull geometry, unassigned materials, invisible objects.
if (
node.name.match(/^Hulk/i) ||
node.material?.name === "Unassigned" ||
(node.userData?.vis ?? 1) < 0.01
) {
node.visible = false;
return;
}
// Filter hull-influenced triangles and smooth normals.
if (node.geometry) {
let geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices,
);
geometry = geometry.clone();
smoothVertexNormals(geometry);
node.geometry = geometry;
}
// Replace PBR materials with diffuse-only Lambert materials.
const vis: number = node.userData?.vis ?? 1;
if (Array.isArray(node.material)) {
node.material = node.material.map((m: MeshStandardMaterial) =>
replaceWithShapeMaterial(m, vis),
);
} else if (node.material) {
node.material = replaceWithShapeMaterial(node.material, vis);
}
});
}
export function collectSceneObjectCounts(scene: Object3D): {
sceneObjects: number;
visibleSceneObjects: number;
} {
let sceneObjects = 0;
let visibleSceneObjects = 0;
scene.traverse((node) => {
sceneObjects += 1;
if (node.visible) {
visibleSceneObjects += 1;
}
});
return { sceneObjects, visibleSceneObjects };
}
export function streamSnapshotSignature(snapshot: DemoStreamSnapshot): string {
const parts: string[] = [];
for (const entity of snapshot.entities) {
const visualPart =
entity.visual?.kind === "tracer"
? `tracer:${entity.visual.texture}:${entity.visual.crossTexture ?? ""}:${entity.visual.tracerLength}:${entity.visual.tracerWidth}:${entity.visual.crossViewAng}:${entity.visual.crossSize}:${entity.visual.renderCross ? 1 : 0}`
: entity.visual?.kind === "sprite"
? `sprite:${entity.visual.texture}:${entity.visual.color.r}:${entity.visual.color.g}:${entity.visual.color.b}:${entity.visual.size}`
: "";
parts.push(
`${entity.id}|${entity.type}|${entity.dataBlock ?? ""}|${entity.weaponShape ?? ""}|${entity.playerName ?? ""}|${entity.className ?? ""}|${entity.ghostIndex ?? ""}|${entity.dataBlockId ?? ""}|${entity.shapeHint ?? ""}|${entity.faceViewer ? "fv" : ""}|${visualPart}`,
);
}
parts.sort();
return parts.join(";");
}
export function buildStreamDemoEntity(
id: string,
type: string,
dataBlock: string | undefined,
visual: DemoEntity["visual"] | undefined,
direction: DemoEntity["direction"] | undefined,
weaponShape: string | undefined,
playerName: string | undefined,
className: string | undefined,
ghostIndex: number | undefined,
dataBlockId: number | undefined,
shapeHint: string | undefined,
): DemoEntity {
return {
id,
type,
dataBlock,
visual,
direction,
weaponShape,
playerName,
className,
ghostIndex,
dataBlockId,
shapeHint,
keyframes: [
{
time: 0,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
},
],
};
}
export function hasAncestorNamed(object: Object3D | null, name: string): boolean {
let node: Object3D | null = object;
while (node) {
if (node.name === name) return true;
node = node.parent;
}
return false;
}
export function entityTypeColor(type: string): string {
switch (type.toLowerCase()) {
case "player":
return "#00ff88";
case "vehicle":
return "#ff8800";
case "projectile":
return "#ff0044";
case "deployable":
return "#ffcc00";
default:
return "#8888ff";
}
}

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,12 @@ interface MutableStreamEntity {
health?: number;
energy?: number;
maxEnergy?: number;
/** Action animation index from ghost ActionMask. */
actionAnim?: number;
/** True when the action animation has reached its final frame. */
actionAtEnd?: boolean;
/** Torque DamageState: 0 = Enabled, 1 = Disabled (dead), 2 = Destroyed. */
damageState?: number;
targetId?: number;
/** Physics type for per-tick simulation. */
projectilePhysics?: "linear" | "ballistic" | "seeker";
@ -709,6 +715,25 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.entitiesById.set(id, entity);
this.state.entityIdByGhostIndex.set(ghost.index, id);
}
// Derive playerSensorGroup from the control player entity if not yet set
// (the SetSensorGroupEvent may not have arrived in the initial block).
if (
this.state.playerSensorGroup === 0 &&
this.state.lastControlType === "player" &&
this.state.latestControl.ghostIndex >= 0
) {
const ctrlId = this.state.entityIdByGhostIndex.get(
this.state.latestControl.ghostIndex,
);
const ctrlEntity = ctrlId
? this.state.entitiesById.get(ctrlId)
: undefined;
if (ctrlEntity?.sensorGroup != null && ctrlEntity.sensorGroup > 0) {
this.state.playerSensorGroup = ctrlEntity.sensorGroup;
}
}
this.updateCameraAndHud();
}
@ -1295,6 +1320,14 @@ class StreamingPlayback implements DemoStreamingPlayback {
if (typeof data.damageLevel === "number") {
entity.health = clamp(1 - data.damageLevel, 0, 1);
}
if (typeof data.damageState === "number") {
entity.damageState = data.damageState;
}
if (typeof data.action === "number") {
entity.actionAnim = data.action;
entity.actionAtEnd = !!data.actionAtEnd;
}
if (typeof data.energy === "number") {
entity.energy = clamp(data.energy, 0, 1);
@ -1523,6 +1556,9 @@ class StreamingPlayback implements DemoStreamingPlayback {
velocity: entity.velocity,
health: entity.health,
energy: entity.energy,
actionAnim: entity.actionAnim,
actionAtEnd: entity.actionAtEnd,
damageState: entity.damageState,
faceViewer: entity.faceViewer,
});
}

View file

@ -12,6 +12,13 @@ export interface DemoKeyframe {
health?: number;
/** Normalized energy (0 = empty, 1 = full). Derived from ghost energyPercent. */
energy?: number;
/** Torque DamageState: 0 = Enabled, 1 = Disabled (dead), 2 = Destroyed. */
damageState?: number;
/** Action animation index from ghost ActionMask (indices >= 7 are non-table
* actions like death animations). */
actionAnim?: number;
/** True when the action animation has reached its final frame. */
actionAtEnd?: boolean;
}
export interface DemoTracerVisual {
@ -119,6 +126,9 @@ export interface DemoStreamEntity {
velocity?: [number, number, number];
health?: number;
energy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
faceViewer?: boolean;
}