various fixes and performance improvements

This commit is contained in:
Brian Beck 2026-03-05 15:00:05 -08:00
parent cb28b66dad
commit 0c9ddb476a
62 changed files with 3109 additions and 1286 deletions

2
.gitignore vendored
View file

@ -137,3 +137,5 @@ dist
# list of files. Once someone builds this, it's not really necessary for other # list of files. Once someone builds this, it's not really necessary for other
# developers to have this folder. # developers to have this folder.
GameData GameData
docs/dev

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,11 +1,11 @@
1:"$Sreact.fragment" 1:"$Sreact.fragment"
2:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 2:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
3:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js","/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js"],"default"] 3:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js"],"default"]
6:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 6:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
7:"$Sreact.suspense" 7:"$Sreact.suspense"
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
:HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"] :HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true}],["$","script","script-7",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
4:{} 4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params" 5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
8:null 8:null

View file

@ -3,16 +3,16 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
6:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js","/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js"],"default"] 6:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
a:"$Sreact.suspense" a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"] c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
10:I[68027,[],"default"] 10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
:HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"] :HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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/b238e7ff5406aa8f.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.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":"6xhnTWazjCi9htjLDl3f1","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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/3e57999e46d7efb4.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.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:{} 7:{}
8:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params" 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"}]] 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/2f236954d6a65e12.js"],"MetadataBoundary"] 3:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
4:"$Sreact.suspense" 4:"$Sreact.suspense"
5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"] 5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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":"6xhnTWazjCi9htjLDl3f1","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

@ -3,4 +3,4 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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,4 +1,4 @@
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
:HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"] :HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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":"6xhnTWazjCi9htjLDl3f1","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

@ -6,6 +6,7 @@
.CopyCoordinatesButton-module__BxovtG__Root{}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true]{background:#0075d5e6;border-color:#fff6}.CopyCoordinatesButton-module__BxovtG__ClipboardCheck{opacity:1;display:none}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true] .CopyCoordinatesButton-module__BxovtG__ClipboardCheck{animation:.22s linear infinite CopyCoordinatesButton-module__BxovtG__showClipboardCheck;display:block}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true] .CopyCoordinatesButton-module__BxovtG__MapPin{display:none}.CopyCoordinatesButton-module__BxovtG__ButtonLabel{}@keyframes CopyCoordinatesButton-module__BxovtG__showClipboardCheck{0%{opacity:1}to{opacity:.2}} .CopyCoordinatesButton-module__BxovtG__Root{}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true]{background:#0075d5e6;border-color:#fff6}.CopyCoordinatesButton-module__BxovtG__ClipboardCheck{opacity:1;display:none}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true] .CopyCoordinatesButton-module__BxovtG__ClipboardCheck{animation:.22s linear infinite CopyCoordinatesButton-module__BxovtG__showClipboardCheck;display:block}.CopyCoordinatesButton-module__BxovtG__Root[data-copied=true] .CopyCoordinatesButton-module__BxovtG__MapPin{display:none}.CopyCoordinatesButton-module__BxovtG__ButtonLabel{}@keyframes CopyCoordinatesButton-module__BxovtG__showClipboardCheck{0%{opacity:1}to{opacity:.2}}
.LoadDemoButton-module__kGZaoW__Root{}.LoadDemoButton-module__kGZaoW__ButtonLabel{}.LoadDemoButton-module__kGZaoW__DemoIcon{font-size:19px} .LoadDemoButton-module__kGZaoW__Root{}.LoadDemoButton-module__kGZaoW__ButtonLabel{}.LoadDemoButton-module__kGZaoW__DemoIcon{font-size:19px}
.PlayerNameplate-module__zYDm0a__Root{pointer-events:none;white-space:nowrap;flex-direction:column;align-items:center;display:inline-flex}.PlayerNameplate-module__zYDm0a__Top{padding-bottom:20px;}.PlayerNameplate-module__zYDm0a__Bottom{padding-top:20px;}.PlayerNameplate-module__zYDm0a__IffArrow{width:12px;height:12px;image-rendering:pixelated;filter:drop-shadow(0 1px 2px #000000b3)}.PlayerNameplate-module__zYDm0a__Name{color:#fff;text-shadow:0 1px 3px #000000e6,0 0 1px #000000b3;font-size:11px}.PlayerNameplate-module__zYDm0a__HealthBar{background:#00000080;border:1px solid #fff3;width:60px;height:4px;margin:2px auto 0;overflow:hidden}.PlayerNameplate-module__zYDm0a__HealthFill{background:#2ecc40;height:100%} .PlayerNameplate-module__zYDm0a__Root{pointer-events:none;white-space:nowrap;flex-direction:column;align-items:center;display:inline-flex}.PlayerNameplate-module__zYDm0a__Top{padding-bottom:20px;}.PlayerNameplate-module__zYDm0a__Bottom{padding-top:20px;}.PlayerNameplate-module__zYDm0a__IffArrow{width:12px;height:12px;image-rendering:pixelated;filter:drop-shadow(0 1px 2px #000000b3)}.PlayerNameplate-module__zYDm0a__Name{color:#fff;text-shadow:0 1px 3px #000000e6,0 0 1px #000000b3;font-size:11px}.PlayerNameplate-module__zYDm0a__HealthBar{background:#00000080;border:1px solid #fff3;width:60px;height:4px;margin:2px auto 0;overflow:hidden}.PlayerNameplate-module__zYDm0a__HealthFill{background:#2ecc40;height:100%}
.FlagMarker-module__INpLba__Root{pointer-events:none;white-space:nowrap;flex-direction:column;align-items:center;gap:1px;display:inline-flex}.FlagMarker-module__INpLba__Distance{color:#fff;text-shadow:0 1px 3px #000000e6,0 0 1px #000000b3;opacity:.5;font-size:10px}.FlagMarker-module__INpLba__Icon{width:16px;height:16px;image-rendering:pixelated;opacity:.5;filter:drop-shadow(0 1px 3px #000c);-webkit-mask-image:var(--flag-icon-url);mask-image:var(--flag-icon-url);-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--flag-icon-url);-webkit-mask-position:50%;-webkit-mask-size:contain;-webkit-mask-repeat:no-repeat}
.DemoControls-module__PjV4fq__Root{color:#fff;z-index:2;background:#000000b3;align-items:center;gap:10px;padding:8px 12px;font-size:13px;display:flex;position:fixed;bottom:0;left:0;right:0}.DemoControls-module__PjV4fq__PlayPause{color:#fff;cursor:pointer;background:#03529399;border:1px solid #ffffff4d;border-radius:4px;flex-shrink:0;justify-content:center;align-items:center;width:32px;height:32px;padding:0;font-size:14px;display:flex}@media (hover:hover){.DemoControls-module__PjV4fq__PlayPause:hover{background:#0062b3cc}}.DemoControls-module__PjV4fq__Time{font-variant-numeric:tabular-nums;white-space:nowrap;flex-shrink:0}.DemoControls-module__PjV4fq__Seek[type=range]{flex:1 1 0;min-width:0;max-width:none}.DemoControls-module__PjV4fq__Speed{color:#fff;background:#0009;border:1px solid #ffffff4d;border-radius:3px;flex-shrink:0;padding:2px 4px;font-size:12px} .DemoControls-module__PjV4fq__Root{color:#fff;z-index:2;background:#000000b3;align-items:center;gap:10px;padding:8px 12px;font-size:13px;display:flex;position:fixed;bottom:0;left:0;right:0}.DemoControls-module__PjV4fq__PlayPause{color:#fff;cursor:pointer;background:#03529399;border:1px solid #ffffff4d;border-radius:4px;flex-shrink:0;justify-content:center;align-items:center;width:32px;height:32px;padding:0;font-size:14px;display:flex}@media (hover:hover){.DemoControls-module__PjV4fq__PlayPause:hover{background:#0062b3cc}}.DemoControls-module__PjV4fq__Time{font-variant-numeric:tabular-nums;white-space:nowrap;flex-shrink:0}.DemoControls-module__PjV4fq__Seek[type=range]{flex:1 1 0;min-width:0;max-width:none}.DemoControls-module__PjV4fq__Speed{color:#fff;background:#0009;border:1px solid #ffffff4d;border-radius:3px;flex-shrink:0;padding:2px 4px;font-size:12px}
.PlayerHUD-module__-E1Scq__PlayerHUD{z-index:1;pointer-events:none;position:absolute;inset:0}.PlayerHUD-module__-E1Scq__TopRight{align-items:flex-start;gap:6px;display:flex;position:absolute;top:56px;right:8px}.PlayerHUD-module__-E1Scq__Compass{flex-shrink:0;width:64px;height:64px;position:relative}.PlayerHUD-module__-E1Scq__CompassRing{image-rendering:auto;width:100%;height:100%;position:absolute;top:0;left:0}.PlayerHUD-module__-E1Scq__CompassNSEW{width:100%;height:100%;image-rendering:pixelated;position:absolute;top:0;left:0}.PlayerHUD-module__-E1Scq__Bars{flex-direction:column;gap:3px;padding-top:10px;display:flex}.PlayerHUD-module__-E1Scq__BarTrack{background:#00000080;border:1px solid #ffffff26;width:120px;height:10px;overflow:hidden}.PlayerHUD-module__-E1Scq__BarFillHealth{background:#2ecc40;height:100%;transition:width .15s ease-out}.PlayerHUD-module__-E1Scq__BarFillEnergy{background:#0af;height:100%;transition:width .15s ease-out}.PlayerHUD-module__-E1Scq__WeaponHUD{flex-direction:column;gap:2px;display:flex;position:absolute;top:50%;right:8px;transform:translateY(-50%)}.PlayerHUD-module__-E1Scq__WeaponSeparator{height:6px}.PlayerHUD-module__-E1Scq__ChatWindow{background:#00323ca6;max-width:420px;padding:4px 8px;font-size:12px;line-height:1.3;position:absolute;top:56px;left:0}.PlayerHUD-module__-E1Scq__ChatMessage{color:#2cacb5;padding:1px 0;transition:opacity .3s ease-out}.PlayerHUD-module__-E1Scq__ChatColor0{color:#2cacb5}.PlayerHUD-module__-E1Scq__ChatColor1{color:#04eb69}.PlayerHUD-module__-E1Scq__ChatColor2{color:#dbc880}.PlayerHUD-module__-E1Scq__ChatColor3{color:#4dfd5f}.PlayerHUD-module__-E1Scq__ChatColor4{color:#28e7f0}.PlayerHUD-module__-E1Scq__ChatColor5{color:#c8c832}.PlayerHUD-module__-E1Scq__ChatColor6{color:#c8c8c8}.PlayerHUD-module__-E1Scq__ChatColor7{color:#dcdc14}.PlayerHUD-module__-E1Scq__ChatColor8{color:#9696fa}.PlayerHUD-module__-E1Scq__ChatColor9{color:#3cdc96}.PlayerHUD-module__-E1Scq__TeamScores{font-family:monospace;font-size:12px;position:absolute;bottom:130px;left:0}.PlayerHUD-module__-E1Scq__TeamRow{background:#00323ca6;gap:6px;padding:2px 8px;display:flex}.PlayerHUD-module__-E1Scq__TeamRow+.PlayerHUD-module__-E1Scq__TeamRow{border-top:1px solid #80ffc826}.PlayerHUD-module__-E1Scq__TeamNameFriendly{color:#2ecc40;min-width:60px}.PlayerHUD-module__-E1Scq__TeamNameEnemy{color:#e44;min-width:60px}.PlayerHUD-module__-E1Scq__TeamScore{color:#fff;text-align:right;min-width:24px;font-weight:700}.PlayerHUD-module__-E1Scq__TeamCount{color:#9ba;text-align:right;min-width:24px}.PlayerHUD-module__-E1Scq__PackInventoryHUD{align-items:center;gap:4px;display:flex;position:absolute;bottom:100px;right:8px}.PlayerHUD-module__-E1Scq__PackInvItem{background:#00323ca6;border:1px solid #80ffc826;flex-direction:column;justify-content:center;align-items:center;gap:1px;padding:4px;display:flex}.PlayerHUD-module__-E1Scq__PackInvItemActive{border-color:#80ffc880;box-shadow:0 0 6px #80ffc84d}.PlayerHUD-module__-E1Scq__PackInvItemDim{opacity:.5}.PlayerHUD-module__-E1Scq__PackInvIcon{image-rendering:pixelated;display:block}.PlayerHUD-module__-E1Scq__PackInvCount{color:#bfe;text-align:center;min-width:12px;font-family:monospace;font-size:11px}.PlayerHUD-module__-E1Scq__PackInvInfinity{image-rendering:pixelated;opacity:.8;display:block}.PlayerHUD-module__-E1Scq__Reticle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.PlayerHUD-module__-E1Scq__ReticleImage{opacity:.85;width:64px;height:64px;image-rendering:pixelated}.PlayerHUD-module__-E1Scq__ReticleDot{background:#2ecc40b3;border-radius:50%;width:4px;height:4px;box-shadow:0 0 4px #2ecc4080} .PlayerHUD-module__-E1Scq__PlayerHUD{z-index:1;pointer-events:none;position:absolute;inset:0}.PlayerHUD-module__-E1Scq__TopRight{align-items:flex-start;gap:6px;display:flex;position:absolute;top:56px;right:8px}.PlayerHUD-module__-E1Scq__Compass{flex-shrink:0;width:64px;height:64px;position:relative}.PlayerHUD-module__-E1Scq__CompassRing{image-rendering:auto;width:100%;height:100%;position:absolute;top:0;left:0}.PlayerHUD-module__-E1Scq__CompassNSEW{width:100%;height:100%;image-rendering:pixelated;position:absolute;top:0;left:0}.PlayerHUD-module__-E1Scq__Bars{flex-direction:column;gap:3px;padding-top:10px;display:flex}.PlayerHUD-module__-E1Scq__BarTrack{background:#00000080;border:1px solid #ffffff26;width:120px;height:10px;overflow:hidden}.PlayerHUD-module__-E1Scq__BarFillHealth{background:#2ecc40;height:100%;transition:width .15s ease-out}.PlayerHUD-module__-E1Scq__BarFillEnergy{background:#0af;height:100%;transition:width .15s ease-out}.PlayerHUD-module__-E1Scq__WeaponHUD{flex-direction:column;gap:2px;display:flex;position:absolute;top:50%;right:8px;transform:translateY(-50%)}.PlayerHUD-module__-E1Scq__WeaponSeparator{height:6px}.PlayerHUD-module__-E1Scq__ChatWindow{background:#00323ca6;max-width:420px;padding:4px 8px;font-size:12px;line-height:1.3;position:absolute;top:56px;left:0}.PlayerHUD-module__-E1Scq__ChatMessage{color:#2cacb5;padding:1px 0;transition:opacity .3s ease-out}.PlayerHUD-module__-E1Scq__ChatColor0{color:#2cacb5}.PlayerHUD-module__-E1Scq__ChatColor1{color:#04eb69}.PlayerHUD-module__-E1Scq__ChatColor2{color:#dbc880}.PlayerHUD-module__-E1Scq__ChatColor3{color:#4dfd5f}.PlayerHUD-module__-E1Scq__ChatColor4{color:#28e7f0}.PlayerHUD-module__-E1Scq__ChatColor5{color:#c8c832}.PlayerHUD-module__-E1Scq__ChatColor6{color:#c8c8c8}.PlayerHUD-module__-E1Scq__ChatColor7{color:#dcdc14}.PlayerHUD-module__-E1Scq__ChatColor8{color:#9696fa}.PlayerHUD-module__-E1Scq__ChatColor9{color:#3cdc96}.PlayerHUD-module__-E1Scq__TeamScores{font-family:monospace;font-size:12px;position:absolute;bottom:130px;left:0}.PlayerHUD-module__-E1Scq__TeamRow{background:#00323ca6;gap:6px;padding:2px 8px;display:flex}.PlayerHUD-module__-E1Scq__TeamRow+.PlayerHUD-module__-E1Scq__TeamRow{border-top:1px solid #80ffc826}.PlayerHUD-module__-E1Scq__TeamNameFriendly{color:#2ecc40;min-width:60px}.PlayerHUD-module__-E1Scq__TeamNameEnemy{color:#e44;min-width:60px}.PlayerHUD-module__-E1Scq__TeamScore{color:#fff;text-align:right;min-width:24px;font-weight:700}.PlayerHUD-module__-E1Scq__TeamCount{color:#9ba;text-align:right;min-width:24px}.PlayerHUD-module__-E1Scq__PackInventoryHUD{align-items:center;gap:4px;display:flex;position:absolute;bottom:100px;right:8px}.PlayerHUD-module__-E1Scq__PackInvItem{background:#00323ca6;border:1px solid #80ffc826;flex-direction:column;justify-content:center;align-items:center;gap:1px;padding:4px;display:flex}.PlayerHUD-module__-E1Scq__PackInvItemActive{border-color:#80ffc880;box-shadow:0 0 6px #80ffc84d}.PlayerHUD-module__-E1Scq__PackInvItemDim{opacity:.5}.PlayerHUD-module__-E1Scq__PackInvIcon{image-rendering:pixelated;display:block}.PlayerHUD-module__-E1Scq__PackInvCount{color:#bfe;text-align:center;min-width:12px;font-family:monospace;font-size:11px}.PlayerHUD-module__-E1Scq__PackInvInfinity{image-rendering:pixelated;opacity:.8;display:block}.PlayerHUD-module__-E1Scq__Reticle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.PlayerHUD-module__-E1Scq__ReticleImage{opacity:.85;width:64px;height:64px;image-rendering:pixelated}.PlayerHUD-module__-E1Scq__ReticleDot{background:#2ecc40b3;border-radius:50%;width:4px;height:4px;box-shadow:0 0 4px #2ecc4080}
.page-module__E0kJGG__CanvasContainer{z-index:0;position:absolute;inset:0}.page-module__E0kJGG__LoadingIndicator{pointer-events:none;z-index:1;opacity:.8;flex-direction:column;align-items:center;gap:16px;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.page-module__E0kJGG__LoadingIndicator[data-complete=true]{animation:.3s ease-out forwards page-module__E0kJGG__loadingComplete}.page-module__E0kJGG__Spinner{border:4px solid #fff3;border-top-color:#fff;border-radius:50%;width:48px;height:48px;animation:1s linear infinite page-module__E0kJGG__spin}.page-module__E0kJGG__Progress{background:#fff3;border-radius:2px;width:200px;height:4px;overflow:hidden}.page-module__E0kJGG__ProgressBar{background:#fff;border-radius:2px;height:100%;transition:width .1s ease-out}.page-module__E0kJGG__ProgressText{color:#ffffffb3;font-variant-numeric:tabular-nums;font-size:14px}@keyframes page-module__E0kJGG__spin{to{transform:rotate(360deg)}}@keyframes page-module__E0kJGG__loadingComplete{0%{opacity:1}to{opacity:0}} .page-module__E0kJGG__CanvasContainer{z-index:0;position:absolute;inset:0}.page-module__E0kJGG__LoadingIndicator{pointer-events:none;z-index:1;opacity:.8;flex-direction:column;align-items:center;gap:16px;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.page-module__E0kJGG__LoadingIndicator[data-complete=true]{animation:.3s ease-out forwards page-module__E0kJGG__loadingComplete}.page-module__E0kJGG__Spinner{border:4px solid #fff3;border-top-color:#fff;border-radius:50%;width:48px;height:48px;animation:1s linear infinite page-module__E0kJGG__spin}.page-module__E0kJGG__Progress{background:#fff3;border-radius:2px;width:200px;height:4px;overflow:hidden}.page-module__E0kJGG__ProgressBar{background:#fff;border-radius:2px;height:100%;transition:width .1s ease-out}.page-module__E0kJGG__ProgressText{color:#ffffffb3;font-variant-numeric:tabular-nums;font-size:14px}@keyframes page-module__E0kJGG__spin{to{transform:rotate(360deg)}}@keyframes page-module__E0kJGG__loadingComplete{0%{opacity:1}to{opacity:0}}

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

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

@ -8,7 +8,7 @@
a:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] a:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
c:I[68027,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] c:I[68027,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","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/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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} 0:{"P":null,"b":"6xhnTWazjCi9htjLDl3f1","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/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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"}]] 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/2f236954d6a65e12.js"],"IconMark"] d:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"]
7:null 7:null

View file

@ -3,4 +3,4 @@
3:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] 3:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
4:"$Sreact.suspense" 4:"$Sreact.suspense"
5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"] 5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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":"6xhnTWazjCi9htjLDl3f1","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

@ -3,4 +3,4 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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" 1:"$Sreact.fragment"
2:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 2:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
3:"$Sreact.suspense" 3:"$Sreact.suspense"
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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":"6xhnTWazjCi9htjLDl3f1","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 4:null

View file

@ -1,4 +1,4 @@
1:"$Sreact.fragment" 1:"$Sreact.fragment"
2:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 2:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
3:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"loading":null,"isPartial":false} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","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/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","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

@ -8,7 +8,7 @@
a:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] a:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
c:I[68027,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] c:I[68027,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","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/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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} 0:{"P":null,"b":"6xhnTWazjCi9htjLDl3f1","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/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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"}]] 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/2f236954d6a65e12.js"],"IconMark"] d:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"]
7:null 7:null

File diff suppressed because one or more lines are too long

View file

@ -3,16 +3,16 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
6:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js","/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js"],"default"] 6:I[31713,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/94212136ebe55507.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
a:"$Sreact.suspense" a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"] c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
10:I[68027,[],"default"] 10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
:HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"] :HL["/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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/b238e7ff5406aa8f.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/f4c5b6b3116ac3bb.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/19dee6e9ded353fa.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/1f24b5e35a3d7706.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.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":"6xhnTWazjCi9htjLDl3f1","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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/3e57999e46d7efb4.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3a7943ba4f8effca.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/29cbf5720c3c6313.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/90c5f23d057a7dda.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/c0475cead0a67c33.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/07f1e4bb8e7d8066.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/d96f10e4606ed566.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/44bbdd420cb3ec27.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/t2-mapper/_next/static/chunks/94212136ebe55507.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:{} 7:{}
8:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params" 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"}]] 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,15 +3,15 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
6:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/325897a61efa2ade.js"],"default"] 6:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
a:"$Sreact.suspense" a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"] c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
10:I[68027,[],"default"] 10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","c":["","shapes",""],"q":"","i":false,"f":[[["",{"children":["shapes",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/325897a61efa2ade.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},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":"6xhnTWazjCi9htjLDl3f1","c":["","shapes",""],"q":"","i":false,"f":[[["",{"children":["shapes",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},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:{} 7:{}
8:"$0:f:0:1:1:children:1:children:0:props:children:0:props:serverProvidedParams:params" 8:"$0:f:0:1:1:children: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"}]] 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/2f236954d6a65e12.js"],"MetadataBoundary"] 3:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
4:"$Sreact.suspense" 4:"$Sreact.suspense"
5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"] 5:I[27201,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"IconMark"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","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":"6xhnTWazjCi9htjLDl3f1","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

@ -3,4 +3,4 @@
3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"shapes","paramType":null,"paramKey":"shapes","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"shapes","paramType":null,"paramKey":"shapes","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}

View file

@ -1,10 +1,10 @@
1:"$Sreact.fragment" 1:"$Sreact.fragment"
2:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 2:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
3:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/325897a61efa2ade.js"],"default"] 3:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js"],"default"]
6:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 6:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
7:"$Sreact.suspense" 7:"$Sreact.suspense"
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
0:{"buildId":"Dy_yQyXNreDeI2LPQRYzt","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/325897a61efa2ade.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false} 0:{"buildId":"6xhnTWazjCi9htjLDl3f1","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","precedence":"next"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","async":true}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
4:{} 4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params" 5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
8:null 8:null

View file

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

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/2f236954d6a65e12.js"],"default"] 3:I[39756,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"] 4:I[37457,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"default"]
5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"] 5:I[47257,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ClientPageRoot"]
6:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/325897a61efa2ade.js"],"default"] 6:I[39724,["/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.js","/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","/t2-mapper/_next/static/chunks/153d5796298dee1e.js","/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js"],"default"]
9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"] 9:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"OutletBoundary"]
a:"$Sreact.suspense" a:"$Sreact.suspense"
c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"] c:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"ViewportBoundary"]
e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"] e:I[97367,["/t2-mapper/_next/static/chunks/2f236954d6a65e12.js"],"MetadataBoundary"]
10:I[68027,[],"default"] 10:I[68027,[],"default"]
:HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"] :HL["/t2-mapper/_next/static/chunks/e620039d1c837dab.css","style"]
:HL["/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","style"] :HL["/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","style"]
0:{"P":null,"b":"Dy_yQyXNreDeI2LPQRYzt","c":["","shapes",""],"q":"","i":false,"f":[[["",{"children":["shapes",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/b238e7ff5406aa8f.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/f299abcdb625bb54.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/325897a61efa2ade.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},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":"6xhnTWazjCi9htjLDl3f1","c":["","shapes",""],"q":"","i":false,"f":[[["",{"children":["shapes",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/e620039d1c837dab.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/89fcb9c19e93d0ef.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":[["$","$L5",null,{"Component":"$6","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@7","$@8"]}}],[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/3e57999e46d7efb4.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/b00acbf8afd8b4b6.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/153d5796298dee1e.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/91476c9d2f29d071.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/53a9c1169e187e33.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/t2-mapper/_next/static/chunks/20b7c805b0b1f5f3.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/t2-mapper/_next/static/chunks/fe8d2153bfb8c263.js","async":true,"nonce":"$undefined"}]],["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},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:{} 7:{}
8:"$0:f:0:1:1:children:1:children:0:props:children:0:props:serverProvidedParams:params" 8:"$0:f:0:1:1:children: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"}]] 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

@ -7,6 +7,7 @@ import {
} from "react"; } from "react";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { AudioListener, AudioLoader } from "three"; import { AudioListener, AudioLoader } from "three";
import { engineStore } from "../state";
interface AudioContextType { interface AudioContextType {
audioLoader: AudioLoader | null; audioLoader: AudioLoader | null;
@ -44,6 +45,26 @@ export function AudioProvider({ children }: { children: ReactNode }) {
audioLoader, audioLoader,
audioListener: listener, audioListener: listener,
}); });
// Suspend/resume the Web AudioContext when demo playback pauses/resumes.
// This freezes all playing sounds at their current position rather than
// stopping them, so they resume seamlessly.
const unsubscribe = engineStore.subscribe(
(state) => state.playback.status,
(status) => {
const ctx = listener?.context;
if (!ctx) return;
if (status === "paused") {
ctx.suspend();
} else if (status === "playing" && ctx.state === "suspended") {
ctx.resume();
}
},
);
return () => {
unsubscribe();
};
}, [camera]); }, [camera]);
return ( return (

View file

@ -14,10 +14,44 @@ import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider"; import { useDebug, useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { engineStore } from "../state";
// Global audio buffer cache shared across all audio components. // Global audio buffer cache shared across all audio components.
export const audioBufferCache = new Map<string, AudioBuffer>(); export const audioBufferCache = new Map<string, AudioBuffer>();
// ── Demo sound rate tracking ──
// Track active demo sounds so their playbackRate can be updated when the
// playback rate changes (e.g. slow-motion or fast-forward).
// Maps each sound to its intrinsic pitch (1.0 for normal sounds, or the
// voice pitch multiplier for chat sounds).
const _activeDemoSounds = new Map<Audio<GainNode | PannerNode>, number>();
/** Register a sound for automatic playback rate tracking. */
export function trackDemoSound(
sound: Audio<GainNode | PannerNode>,
basePitch = 1,
): void {
_activeDemoSounds.set(sound, basePitch);
}
/** Unregister a tracked demo sound. */
export function untrackDemoSound(sound: Audio<GainNode | PannerNode>): void {
_activeDemoSounds.delete(sound);
}
engineStore.subscribe(
(state) => state.playback.rate,
(rate) => {
for (const [sound, basePitch] of _activeDemoSounds) {
try {
sound.setPlaybackRate(basePitch * rate);
} catch {
// Sound may have been disposed.
}
}
},
);
export interface ResolvedAudioProfile { export interface ResolvedAudioProfile {
filename: string; filename: string;
is3D: boolean; is3D: boolean;
@ -73,6 +107,7 @@ export function playOneShotSound(
// File not in manifest — skip silently. // File not in manifest — skip silently.
return; return;
} }
const rate = engineStore.getState().playback.rate;
getCachedAudioBuffer(url, audioLoader, (buffer) => { getCachedAudioBuffer(url, audioLoader, (buffer) => {
try { try {
if (resolved.is3D && parent) { if (resolved.is3D && parent) {
@ -85,12 +120,15 @@ export function playOneShotSound(
sound.setMaxDistance(resolved.maxDist); sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1); sound.setRolloffFactor(1);
sound.setVolume(resolved.volume); sound.setVolume(resolved.volume);
sound.setPlaybackRate(rate);
if (position) { if (position) {
sound.position.copy(position); sound.position.copy(position);
} }
parent.add(sound); parent.add(sound);
_activeDemoSounds.set(sound, 1);
sound.play(); sound.play();
sound.source!.onended = () => { sound.source!.onended = () => {
_activeDemoSounds.delete(sound);
sound.disconnect(); sound.disconnect();
parent.remove(sound); parent.remove(sound);
}; };
@ -98,8 +136,11 @@ export function playOneShotSound(
const sound = new Audio(audioListener); const sound = new Audio(audioListener);
sound.setBuffer(buffer); sound.setBuffer(buffer);
sound.setVolume(resolved.volume); sound.setVolume(resolved.volume);
sound.setPlaybackRate(rate);
_activeDemoSounds.set(sound, 1);
sound.play(); sound.play();
sound.source!.onended = () => { sound.source!.onended = () => {
_activeDemoSounds.delete(sound);
sound.disconnect(); sound.disconnect();
}; };
} }

View file

@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
import { Audio } from "three"; import { Audio } from "three";
import { audioToUrl } from "../loaders"; import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { getCachedAudioBuffer } from "./AudioEmitter"; import { getCachedAudioBuffer, trackDemoSound, untrackDemoSound } from "./AudioEmitter";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { useEngineSelector } from "../state"; import { engineStore, useEngineSelector } from "../state";
import type { DemoChatMessage } from "../demo/types"; import type { DemoChatMessage } from "../demo/types";
/** /**
@ -21,7 +21,12 @@ export function ChatSoundPlayer() {
const timeSec = useEngineSelector( const timeSec = useEngineSelector(
(state) => state.playback.streamSnapshot?.timeSec, (state) => state.playback.streamSnapshot?.timeSec,
); );
const playedCountRef = useRef(0); const playedSetRef = useRef(new WeakSet<DemoChatMessage>());
// Track active voice chat sound per sender so a new voice bind from the
// same player stops their previous one (matching Tribes 2 behavior).
const activeBySenderRef = useRef(
new Map<string, Audio<GainNode>>(),
);
useEffect(() => { useEffect(() => {
if ( if (
@ -33,32 +38,51 @@ export function ChatSoundPlayer() {
) { ) {
return; return;
} }
const startIdx = playedCountRef.current; const played = playedSetRef.current;
for (let i = startIdx; i < messages.length; i++) { const activeBySender = activeBySenderRef.current;
const msg: DemoChatMessage = messages[i]; for (const msg of messages) {
if (played.has(msg)) continue;
played.add(msg);
if (!msg.soundPath) continue; if (!msg.soundPath) continue;
// Skip sounds that are too old (e.g. after seeking). // Skip sounds that are too old (e.g. after seeking).
if (Math.abs(timeSec - msg.timeSec) > 2) continue; if (Math.abs(timeSec - msg.timeSec) > 2) continue;
try { try {
const url = audioToUrl(msg.soundPath); const url = audioToUrl(msg.soundPath);
const pitch = msg.soundPitch ?? 1; const pitch = msg.soundPitch ?? 1;
const rate = engineStore.getState().playback.rate;
const sender = msg.sender;
getCachedAudioBuffer(url, audioLoader, (buffer) => { getCachedAudioBuffer(url, audioLoader, (buffer) => {
// Stop the sender's previous voice chat sound.
if (sender) {
const prev = activeBySender.get(sender);
if (prev) {
try { prev.stop(); } catch {}
untrackDemoSound(prev);
prev.disconnect();
activeBySender.delete(sender);
}
}
const sound = new Audio(audioListener); const sound = new Audio(audioListener);
sound.setBuffer(buffer); sound.setBuffer(buffer);
if (pitch !== 1) { sound.setPlaybackRate(pitch * rate);
sound.setPlaybackRate(pitch); trackDemoSound(sound, pitch);
if (sender) {
activeBySender.set(sender, sound);
} }
sound.play(); sound.play();
// Clean up the source node once playback finishes. // Clean up the source node once playback finishes.
sound.source!.onended = () => { sound.source!.onended = () => {
untrackDemoSound(sound);
sound.disconnect(); sound.disconnect();
if (sender && activeBySender.get(sender) === sound) {
activeBySender.delete(sender);
}
}; };
}); });
} catch { } catch {
// File not in manifest — skip silently. // File not in manifest — skip silently.
} }
} }
playedCountRef.current = messages.length;
}, [audioEnabled, audioLoader, audioListener, messages, timeSec]); }, [audioEnabled, audioLoader, audioListener, messages, timeSec]);
return null; return null;

View file

@ -1,14 +1,15 @@
import { Component, Suspense } from "react"; import { Component, memo, Suspense } from "react";
import type { ErrorInfo, MutableRefObject, ReactNode } from "react"; import type { ErrorInfo, MutableRefObject, ReactNode } from "react";
import { entityTypeColor } from "../demo/demoPlaybackUtils"; import { entityTypeColor } from "../demo/demoPlaybackUtils";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider"; import { useDebug } from "./SettingsProvider";
import { DemoPlayerModel } from "./DemoPlayerModel"; import { DemoPlayerModel } from "./DemoPlayerModel";
import { DemoShapeModel, DemoWeaponModel } from "./DemoShapeModel"; import { DemoShapeModel, DemoWeaponModel, DemoExplosionShape } from "./DemoShapeModel";
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles"; import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
import { PlayerNameplate } from "./PlayerNameplate"; import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker";
import { useEngineSelector } from "../state"; import { useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types"; import type { DemoEntity, DemoStreamingPlayback } from "../demo/types";
/** /**
* Renders a non-camera demo entity. * Renders a non-camera demo entity.
@ -16,12 +17,14 @@ import type { DemoEntity } from "../demo/types";
* Player entities use DemoPlayerModel for skeletal animation; others use * Player entities use DemoPlayerModel for skeletal animation; others use
* DemoShapeModel. * DemoShapeModel.
*/ */
export function DemoEntityGroup({ export const DemoEntityGroup = memo(function DemoEntityGroup({
entity, entity,
timeRef, timeRef,
playback,
}: { }: {
entity: DemoEntity; entity: DemoEntity;
timeRef: MutableRefObject<number>; timeRef: MutableRefObject<number>;
playback?: DemoStreamingPlayback;
}) { }) {
const debug = useDebug(); const debug = useDebug();
const debugMode = debug?.debugMode ?? false; const debugMode = debug?.debugMode ?? false;
@ -57,6 +60,7 @@ export function DemoEntityGroup({
} }
if (!entity.dataBlock) { if (!entity.dataBlock) {
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return ( return (
<group name={name}> <group name={name}>
<group name="model"> <group name="model">
@ -66,6 +70,11 @@ export function DemoEntityGroup({
</mesh> </mesh>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null} {debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group> </group>
{isFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group> </group>
); );
} }
@ -80,6 +89,7 @@ export function DemoEntityGroup({
// Player entities use skeleton-preserving DemoPlayerModel for animation. // Player entities use skeleton-preserving DemoPlayerModel for animation.
if (entity.type === "Player") { if (entity.type === "Player") {
const isControlPlayer = entity.id === controlPlayerGhostId; const isControlPlayer = entity.id === controlPlayerGhostId;
const hasFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return ( return (
<group name={name}> <group name={name}>
<group name="model"> <group name="model">
@ -93,11 +103,34 @@ export function DemoEntityGroup({
<PlayerNameplate entity={entity} timeRef={timeRef} /> <PlayerNameplate entity={entity} timeRef={timeRef} />
</Suspense> </Suspense>
)} )}
{hasFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group> </group>
</group> </group>
); );
} }
// Explosion entities with DTS shapes use a specialized renderer
// that handles faceViewer, size keyframes, and fade-out.
if (entity.type === "Explosion" && entity.dataBlock && playback) {
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<DemoExplosionShape entity={entity as any} playback={playback} />
</Suspense>
</ShapeErrorBoundary>
</group>
</group>
);
}
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return ( return (
<group name={name}> <group name={name}>
<group name="model"> <group name="model">
@ -119,9 +152,14 @@ export function DemoEntityGroup({
</ShapeErrorBoundary> </ShapeErrorBoundary>
</group> </group>
)} )}
{isFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group> </group>
); );
} });
export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) { export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) {
const id = String(entity.id); const id = String(entity.id);

View file

@ -3,7 +3,9 @@ import { useFrame, useThree } from "@react-three/fiber";
import { import {
AdditiveBlending, AdditiveBlending,
BoxGeometry, BoxGeometry,
BufferAttribute,
BufferGeometry, BufferGeometry,
CanvasTexture,
DataTexture, DataTexture,
DoubleSide, DoubleSide,
Float32BufferAttribute, Float32BufferAttribute,
@ -15,6 +17,8 @@ import {
RGBAFormat, RGBAFormat,
ShaderMaterial, ShaderMaterial,
SphereGeometry, SphereGeometry,
Sprite,
SpriteMaterial,
Texture, Texture,
Uint16BufferAttribute, Uint16BufferAttribute,
UnsignedByteType, UnsignedByteType,
@ -42,7 +46,10 @@ import {
resolveAudioProfile, resolveAudioProfile,
playOneShotSound, playOneShotSound,
getCachedAudioBuffer, getCachedAudioBuffer,
trackDemoSound,
untrackDemoSound,
} from "./AudioEmitter"; } from "./AudioEmitter";
import { demoEffectNow, engineStore } from "../state";
// ── Constants ── // ── Constants ──
@ -92,10 +99,357 @@ const _debugOriginMat = new MeshBasicMaterial({ color: 0xff0000, wireframe: true
const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3); const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3);
const _debugParticleMat = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true }); const _debugParticleMat = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
// ── sRGB → linear conversion for shader attributes ── // ── Explosion wireframe sphere geometry (reusable) ──
function srgbToLinear(c: number): number { const _explosionSphereGeo = new SphereGeometry(1, 12, 8);
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
interface ActiveExplosionSphere {
entityId: string;
mesh: Mesh;
material: MeshBasicMaterial;
label: Sprite;
labelMaterial: SpriteMaterial;
creationTime: number;
lifetimeMS: number;
targetRadius: number;
}
/** Create a text label sprite for an explosion sphere. */
function createExplosionLabel(text: string, color: number): { sprite: Sprite; material: SpriteMaterial } {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const fontSize = 32;
ctx.font = `bold ${fontSize}px monospace`;
const metrics = ctx.measureText(text);
const padding = 8;
canvas.width = Math.ceil(metrics.width) + padding * 2;
canvas.height = fontSize + padding * 2;
// Redraw with correct canvas size.
ctx.font = `bold ${fontSize}px monospace`;
ctx.fillStyle = `#${color.toString(16).padStart(6, "0")}`;
ctx.textBaseline = "middle";
ctx.fillText(text, padding, canvas.height / 2);
const texture = new CanvasTexture(canvas);
const material = new SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
});
const sprite = new Sprite(material);
// Scale to be readable in world space (roughly 1 unit tall).
const aspect = canvas.width / canvas.height;
sprite.scale.set(aspect * 2, 2, 1);
return { sprite, material };
}
// ── Shockwave ring rendering ──
interface ShockwaveData {
width: number;
numSegments: number;
velocity: number;
height: number;
verticalCurve: number;
acceleration: number;
texWrap: number;
lifetimeMS: number;
is2D: boolean;
renderSquare: boolean;
renderBottom: boolean;
mapToTerrain: boolean;
colors: { r: number; g: number; b: number; a: number }[];
times: number[];
textureName: string;
mapToTexture: string;
}
interface ActiveShockwave {
entityId: string;
mesh: Mesh;
bottomMesh: Mesh | null;
geometry: BufferGeometry;
bottomGeometry: BufferGeometry | null;
material: ShaderMaterial;
creationTime: number;
lifetimeMS: number;
data: ShockwaveData;
radius: number;
velocity: number;
}
/** Resolve a ShockwaveData datablock from an explosion's shockwave ref. */
function resolveShockwaveData(
shockwaveId: number,
getDataBlockData: (id: number) => Record<string, unknown> | undefined,
): ShockwaveData | null {
const raw = getDataBlockData(shockwaveId);
if (!raw) return null;
const colors = (raw.colors as ShockwaveData["colors"]) ?? [];
const times = (raw.times as number[]) ?? [0, 0.5, 1, 1];
return {
width: (raw.width as number) ?? 1,
numSegments: Math.max((raw.numSegments as number) ?? 16, 4),
velocity: (raw.velocity as number) ?? 0,
height: (raw.height as number) ?? 0,
verticalCurve: (raw.verticalCurve as number) ?? 0,
acceleration: (raw.acceleration as number) ?? 0,
texWrap: (raw.texWrap as number) ?? 1,
lifetimeMS: (raw.lifetimeMS as number) ?? 500,
is2D: !!raw.is2D,
renderSquare: !!raw.renderSquare,
renderBottom: !!raw.renderBottom,
mapToTerrain: !!raw.mapToTerrain,
colors,
times,
textureName: (raw.textureName as string) ?? "",
mapToTexture: (raw.mapToTexture as string) ?? "",
};
}
/** Interpolate RGBA color from shockwave keyframes at normalized time t. */
function interpolateShockwaveColor(
data: ShockwaveData,
t: number,
): [number, number, number, number] {
const { colors, times } = data;
if (colors.length === 0) return [1, 1, 1, 1];
// Find the active keyframe segment.
let idx = 0;
for (let i = 0; i < times.length - 1; i++) {
if (t >= times[i]) idx = i;
}
const nextIdx = Math.min(idx + 1, colors.length - 1);
const t0 = times[idx] ?? 0;
const t1 = times[nextIdx] ?? 1;
const span = t1 - t0;
const frac = span > 0 ? Math.min((t - t0) / span, 1) : 0;
const c0 = colors[idx] ?? colors[0];
const c1 = colors[nextIdx] ?? colors[0];
return [
c0.r + (c1.r - c0.r) * frac,
c0.g + (c1.g - c0.g) * frac,
c0.b + (c1.b - c0.b) * frac,
c0.a + (c1.a - c0.a) * frac,
];
}
// Shockwave ring shader — additive blending, vertex colors with alpha.
const shockwaveVertexShader = /* glsl */ `
attribute vec4 vertexColor;
attribute vec2 texCoord;
varying vec4 vColor;
varying vec2 vUV;
void main() {
vColor = vertexColor;
vUV = texCoord;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const shockwaveFragmentShader = /* glsl */ `
uniform sampler2D uTexture;
varying vec4 vColor;
varying vec2 vUV;
void main() {
vec4 tex = texture2D(uTexture, vUV);
gl_FragColor = vec4(vColor.rgb * tex.rgb, vColor.a * tex.a);
}
`;
/**
* Create ring geometry buffers for a shockwave with the given segment count.
* Each segment is a quad (2 triangles) between inner and outer ring vertices.
* Returns the geometry with position, texCoord, vertexColor attributes and
* index buffer pre-allocated for numSegments quads.
*/
function createShockwaveGeometry(numSegments: number): BufferGeometry {
// 2 vertices per segment (inner + outer) + 2 to close the loop.
const numVerts = (numSegments + 1) * 2;
const positions = new Float32Array(numVerts * 3);
const texCoords = new Float32Array(numVerts * 2);
const vertexColors = new Float32Array(numVerts * 4);
// 2 triangles per segment = 6 indices.
const numIndices = numSegments * 6;
const indices = new Uint16Array(numIndices);
for (let i = 0; i < numSegments; i++) {
const base = i * 2;
const j = i * 6;
// Outer-inner-outer, inner-inner-outer (CCW winding).
indices[j] = base;
indices[j + 1] = base + 1;
indices[j + 2] = base + 2;
indices[j + 3] = base + 1;
indices[j + 4] = base + 3;
indices[j + 5] = base + 2;
}
const geo = new BufferGeometry();
const posAttr = new BufferAttribute(positions, 3);
posAttr.setUsage(35048); // DynamicDrawUsage
geo.setAttribute("position", posAttr);
const texAttr = new BufferAttribute(texCoords, 2);
texAttr.setUsage(35048);
geo.setAttribute("texCoord", texAttr);
const colorAttr = new BufferAttribute(vertexColors, 4);
colorAttr.setUsage(35048);
geo.setAttribute("vertexColor", colorAttr);
geo.setIndex(new BufferAttribute(indices, 1));
return geo;
}
/**
* Update shockwave ring vertex positions, UVs, and colors for the current
* frame. Implements the V12 renderWave algorithm: an expanding annular ring
* with optional height on the outer edge.
*/
function updateShockwaveGeometry(
geo: BufferGeometry,
sw: ShockwaveData,
radius: number,
color: [number, number, number, number],
is2D: boolean,
): void {
const posArr = (geo.getAttribute("position") as BufferAttribute)
.array as Float32Array;
const texArr = (geo.getAttribute("texCoord") as BufferAttribute)
.array as Float32Array;
const colArr = (geo.getAttribute("vertexColor") as BufferAttribute)
.array as Float32Array;
const innerRad = Math.max(radius - sw.width * 0.5, 0);
const outerRad = radius + sw.width * 0.5;
const numSegs = sw.numSegments;
// Pass colors as-is (gamma space) — ShaderMaterial has no automatic
// output encoding, matching V12's direct gamma-space rendering.
const lr = color[0];
const lg = color[1];
const lb = color[2];
const la = color[3];
for (let i = 0; i <= numSegs; i++) {
const angle = (i / numSegs) * Math.PI * 2;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// In Three.js space: ring lies in XZ plane, Y is up.
const outerIdx = i * 2;
const innerIdx = outerIdx + 1;
// Outer vertex — raised by height along Y.
const opi = outerIdx * 3;
posArr[opi] = cos * outerRad;
posArr[opi + 1] = is2D ? 0 : sw.height;
posArr[opi + 2] = sin * outerRad;
// Inner vertex — on ground plane.
const ipi = innerIdx * 3;
posArr[ipi] = cos * innerRad;
posArr[ipi + 1] = 0;
posArr[ipi + 2] = sin * innerRad;
// UV: U wraps around ring, V spans inner→outer.
const u = (i / numSegs) * sw.texWrap;
const oti = outerIdx * 2;
texArr[oti] = u;
texArr[oti + 1] = 0.05; // outer edge
const iti = innerIdx * 2;
texArr[iti] = u;
texArr[iti + 1] = 0.95; // inner edge
// Vertex colors (uniform across ring).
const oci = outerIdx * 4;
colArr[oci] = lr;
colArr[oci + 1] = lg;
colArr[oci + 2] = lb;
colArr[oci + 3] = la;
const ici = innerIdx * 4;
colArr[ici] = lr;
colArr[ici + 1] = lg;
colArr[ici + 2] = lb;
colArr[ici + 3] = la;
}
geo.getAttribute("position").needsUpdate = true;
geo.getAttribute("texCoord").needsUpdate = true;
geo.getAttribute("vertexColor").needsUpdate = true;
geo.computeBoundingSphere();
}
/** Create the ShaderMaterial for a shockwave ring. */
function createShockwaveMaterial(texture: Texture): ShaderMaterial {
return new ShaderMaterial({
vertexShader: shockwaveVertexShader,
fragmentShader: shockwaveFragmentShader,
uniforms: { uTexture: { value: texture } },
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
side: DoubleSide,
});
}
/** Map explosion dataBlock shape name to a debug wireframe color. */
function getExplosionColor(dataBlock: string | undefined): number {
if (!dataBlock) return 0xff00ff;
const name = dataBlock.toLowerCase();
if (name.includes("disc")) return 0x4488ff;
if (name.includes("grenade")) return 0xff8800;
if (name.includes("mortar")) return 0xff4400;
if (name.includes("plasma")) return 0x44ff44;
if (name.includes("laser")) return 0xff2222;
if (name.includes("blaster")) return 0xffff00;
if (name.includes("missile")) return 0xff6600;
if (name.includes("bomb")) return 0xff0000;
if (name.includes("mine")) return 0xff8844;
if (name.includes("concussion")) return 0xffaa00;
if (name.includes("shocklance")) return 0x8844ff;
if (name.includes("chaingun") || name.includes("bullet")) return 0xcccccc;
return 0xff00ff;
}
/**
* Extract approximate radius from an ExplosionData datablock's `sizes` array.
* Each entry is `{x, y, z}` with values in range 016000 (scale multiplier).
* Falls back to `particleRadius` or a default of 5.
*/
function getExplosionRadius(
expBlock: Record<string, unknown>,
): number {
const sizes = expBlock.sizes as Array<{ x: number; y: number; z: number }> | undefined;
if (Array.isArray(sizes) && sizes.length > 0) {
let maxVal = 0;
for (const s of sizes) {
maxVal = Math.max(maxVal, s.x, s.y, s.z);
}
if (maxVal > 0) {
// Values are in 016000 range, treat as a scale factor.
// Typical explosions have values like 20008000; map to reasonable world radii.
return maxVal / 1000;
}
}
const particleRadius = expBlock.particleRadius as number | undefined;
if (typeof particleRadius === "number" && particleRadius > 0) {
return particleRadius;
}
return 5;
} }
// ── Geometry builder ── // ── Geometry builder ──
@ -129,6 +483,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
const colors = new Float32Array(vertCount * 4); const colors = new Float32Array(vertCount * 4);
const sizes = new Float32Array(vertCount); const sizes = new Float32Array(vertCount);
const spins = new Float32Array(vertCount); const spins = new Float32Array(vertCount);
const orientDirs = new Float32Array(vertCount * 3);
geo.setIndex(new Uint16BufferAttribute(indices, 1)); geo.setIndex(new Uint16BufferAttribute(indices, 1));
geo.setAttribute("quadCorner", new Float32BufferAttribute(corners, 2)); geo.setAttribute("quadCorner", new Float32BufferAttribute(corners, 2));
@ -136,6 +491,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
geo.setAttribute("particleColor", new Float32BufferAttribute(colors, 4)); geo.setAttribute("particleColor", new Float32BufferAttribute(colors, 4));
geo.setAttribute("particleSize", new Float32BufferAttribute(sizes, 1)); geo.setAttribute("particleSize", new Float32BufferAttribute(sizes, 1));
geo.setAttribute("particleSpin", new Float32BufferAttribute(spins, 1)); geo.setAttribute("particleSpin", new Float32BufferAttribute(spins, 1));
geo.setAttribute("orientDir", new Float32BufferAttribute(orientDirs, 3));
geo.setDrawRange(0, 0); geo.setDrawRange(0, 0);
return geo; return geo;
@ -144,6 +500,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
function createParticleMaterial( function createParticleMaterial(
texture: Texture, texture: Texture,
useInvAlpha: boolean, useInvAlpha: boolean,
orientParticles = false,
): ShaderMaterial { ): ShaderMaterial {
// Use the placeholder until the real texture's image data is ready. // Use the placeholder until the real texture's image data is ready.
const ready = _texturesReady.has(texture); const ready = _texturesReady.has(texture);
@ -153,6 +510,8 @@ function createParticleMaterial(
uniforms: { uniforms: {
particleTexture: { value: ready ? texture : _placeholderTexture }, particleTexture: { value: ready ? texture : _placeholderTexture },
hasTexture: { value: true }, hasTexture: { value: true },
debugOpacity: { value: 1.0 },
uOrientParticles: { value: orientParticles },
}, },
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
@ -178,6 +537,8 @@ interface ActiveEmitter {
shaderChecked?: boolean; shaderChecked?: boolean;
/** Entity ID this emitter follows (for projectile trails). */ /** Entity ID this emitter follows (for projectile trails). */
followEntityId?: string; followEntityId?: string;
/** Emission axis in Torque space (defaults to [0,0,1] = up). */
emitAxis?: [number, number, number];
/** Debug: origin marker mesh. */ /** Debug: origin marker mesh. */
debugOriginMesh?: Mesh; debugOriginMesh?: Mesh;
/** Debug: particle marker meshes. */ /** Debug: particle marker meshes. */
@ -268,13 +629,16 @@ function syncBuffers(active: ActiveEmitter): void {
const colorAttr = geo.getAttribute("particleColor") as Float32BufferAttribute; const colorAttr = geo.getAttribute("particleColor") as Float32BufferAttribute;
const sizeAttr = geo.getAttribute("particleSize") as Float32BufferAttribute; const sizeAttr = geo.getAttribute("particleSize") as Float32BufferAttribute;
const spinAttr = geo.getAttribute("particleSpin") as Float32BufferAttribute; const spinAttr = geo.getAttribute("particleSpin") as Float32BufferAttribute;
const orientAttr = geo.getAttribute("orientDir") as Float32BufferAttribute;
const posArr = posAttr.array as Float32Array; const posArr = posAttr.array as Float32Array;
const colArr = colorAttr.array as Float32Array; const colArr = colorAttr.array as Float32Array;
const sizeArr = sizeAttr.array as Float32Array; const sizeArr = sizeAttr.array as Float32Array;
const spinArr = spinAttr.array as Float32Array; const spinArr = spinAttr.array as Float32Array;
const orientArr = orientAttr.array as Float32Array;
const count = Math.min(particles.length, MAX_PARTICLES_PER_EMITTER); const count = Math.min(particles.length, MAX_PARTICLES_PER_EMITTER);
const useVelocity = active.emitter.data.orientOnVelocity;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const p = particles[i]; const p = particles[i];
@ -284,10 +648,19 @@ function syncBuffers(active: ActiveEmitter): void {
const ty = p.pos[2]; const ty = p.pos[2];
const tz = p.pos[0]; const tz = p.pos[0];
// Convert sRGB particle colors to linear for the shader. // Orient direction: use velocity or initial orientDir, swizzled.
const lr = srgbToLinear(p.r); const dir = useVelocity ? p.vel : p.orientDir;
const lg = srgbToLinear(p.g); const odx = dir[1];
const lb = srgbToLinear(p.b); const ody = dir[2];
const odz = dir[0];
// Pass particle colors as-is (sRGB / gamma space). ShaderMaterial does
// not get automatic linear→sRGB output encoding, so linearizing here
// would darken colors without compensation — matching V12's direct
// gamma-space rendering.
const lr = p.r;
const lg = p.g;
const lb = p.b;
const la = p.a; const la = p.a;
// Write the same values to all 4 vertices of the quad. // Write the same values to all 4 vertices of the quad.
@ -304,6 +677,11 @@ function syncBuffers(active: ActiveEmitter): void {
colArr[ci + 2] = lb; colArr[ci + 2] = lb;
colArr[ci + 3] = la; colArr[ci + 3] = la;
const oi = vi * 3;
orientArr[oi] = odx;
orientArr[oi + 1] = ody;
orientArr[oi + 2] = odz;
sizeArr[vi] = p.size; sizeArr[vi] = p.size;
spinArr[vi] = p.currentSpin; spinArr[vi] = p.currentSpin;
} }
@ -320,6 +698,7 @@ function syncBuffers(active: ActiveEmitter): void {
colorAttr.needsUpdate = true; colorAttr.needsUpdate = true;
sizeAttr.needsUpdate = true; sizeAttr.needsUpdate = true;
spinAttr.needsUpdate = true; spinAttr.needsUpdate = true;
orientAttr.needsUpdate = true;
geo.setDrawRange(0, count * 6); geo.setDrawRange(0, count * 6);
} }
@ -349,12 +728,20 @@ export function DemoParticleEffects({
const projectileSoundsRef = useRef<Map<string, PositionalAudio>>(new Map()); const projectileSoundsRef = useRef<Map<string, PositionalAudio>>(new Map());
/** Track processed audio event keys to prevent replays on seek. */ /** Track processed audio event keys to prevent replays on seek. */
const processedAudioEventsRef = useRef<Set<string>>(new Set()); const processedAudioEventsRef = useRef<Set<string>>(new Set());
useFrame((_, delta) => { /** Active wireframe explosion spheres. */
const activeExplosionSpheresRef = useRef<ActiveExplosionSphere[]>([]);
/** Active shockwave ring effects. */
const activeShockwavesRef = useRef<ActiveShockwave[]>([]);
useFrame((state, delta) => {
const group = groupRef.current; const group = groupRef.current;
const snapshot = snapshotRef.current; const snapshot = snapshotRef.current;
if (!group || !snapshot) return; if (!group || !snapshot) return;
const dtMS = delta * 1000; const playbackState = engineStore.getState().playback;
const isPlaying = playbackState.status === "playing";
// Scale delta by playback rate; 0 when paused.
const effectDelta = isPlaying ? delta * playbackState.rate : 0;
const dtMS = effectDelta * 1000;
const getDataBlockData = playback.getDataBlockData.bind(playback); const getDataBlockData = playback.getDataBlockData.bind(playback);
// Detect new explosion entities and create emitters. // Detect new explosion entities and create emitters.
@ -390,6 +777,7 @@ export function DemoParticleEffects({
const material = createParticleMaterial( const material = createParticleMaterial(
texture, texture,
burst.data.particles.useInvAlpha, burst.data.particles.useInvAlpha,
burst.data.orientParticles,
); );
const mesh = new Mesh(geometry, material); const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false; mesh.frustumCulled = false;
@ -420,6 +808,7 @@ export function DemoParticleEffects({
const material = createParticleMaterial( const material = createParticleMaterial(
texture, texture,
emitterData.particles.useInvAlpha, emitterData.particles.useInvAlpha,
emitterData.orientParticles,
); );
const mesh = new Mesh(geometry, material); const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false; mesh.frustumCulled = false;
@ -436,6 +825,91 @@ export function DemoParticleEffects({
hasBurst: false, hasBurst: false,
}); });
} }
const expBlock = getDataBlockData(entity.explosionDataBlockId);
// Debug mode: show wireframe spheres and labels.
if (debugMode) {
const radius = expBlock ? getExplosionRadius(expBlock) : 5;
const color = getExplosionColor(entity.dataBlock);
const sphereMat = new MeshBasicMaterial({
color,
wireframe: true,
transparent: true,
opacity: 1,
depthWrite: false,
});
const sphereMesh = new Mesh(_explosionSphereGeo, sphereMat);
sphereMesh.frustumCulled = false;
sphereMesh.scale.setScalar(radius);
sphereMesh.position.set(origin[1], origin[2], origin[0]);
group.add(sphereMesh);
const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`;
const { sprite: labelSprite, material: labelMat } = createExplosionLabel(labelText, color);
labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]);
labelSprite.frustumCulled = false;
group.add(labelSprite);
activeExplosionSpheresRef.current.push({
entityId: entity.id as string,
mesh: sphereMesh,
material: sphereMat,
label: labelSprite,
labelMaterial: labelMat,
creationTime: demoEffectNow(),
lifetimeMS: Math.max(resolved.lifetimeMS, 3000),
targetRadius: radius,
});
}
// Spawn shockwave ring if the explosion datablock references one.
const shockwaveId = expBlock?.shockwave as number | null | undefined;
if (typeof shockwaveId === "number") {
const swData = resolveShockwaveData(shockwaveId, getDataBlockData);
if (swData) {
const texture = getParticleTexture(swData.textureName);
const geo = createShockwaveGeometry(swData.numSegments);
const mat = createShockwaveMaterial(texture);
const mesh = new Mesh(geo, mat);
mesh.frustumCulled = false;
mesh.position.set(origin[1], origin[2], origin[0]);
group.add(mesh);
// Optional bottom face (renders the underside of the ring).
let bottomMesh: Mesh | null = null;
let bottomGeo: BufferGeometry | null = null;
if (swData.renderBottom) {
bottomGeo = createShockwaveGeometry(swData.numSegments);
bottomMesh = new Mesh(bottomGeo, mat);
bottomMesh.frustumCulled = false;
bottomMesh.position.set(origin[1], origin[2], origin[0]);
// Flip Y to render the underside.
bottomMesh.scale.y = -1;
group.add(bottomMesh);
}
// Clamp denormalized velocity values (parser bug workaround).
const initVelocity = Math.abs(swData.velocity) > 1e-10
? swData.velocity
: 0;
activeShockwavesRef.current.push({
entityId: entity.id as string,
mesh,
bottomMesh,
geometry: geo,
bottomGeometry: bottomGeo,
material: mat,
creationTime: demoEffectNow(),
lifetimeMS: swData.lifetimeMS,
data: swData,
radius: 0,
velocity: initVelocity,
});
}
}
} }
// Detect projectile entities with trail emitters (maintainEmitterId). // Detect projectile entities with trail emitters (maintainEmitterId).
@ -465,6 +939,7 @@ export function DemoParticleEffects({
const material = createParticleMaterial( const material = createParticleMaterial(
texture, texture,
emitterData.particles.useInvAlpha, emitterData.particles.useInvAlpha,
emitterData.orientParticles,
); );
const mesh = new Mesh(geometry, material); const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false; mesh.frustumCulled = false;
@ -508,7 +983,7 @@ export function DemoParticleEffects({
entry.shaderChecked = true; entry.shaderChecked = true;
} }
// Update trail emitter origin to follow the projectile's position. // Update trail emitter origin and direction to follow the projectile.
if (entry.followEntityId) { if (entry.followEntityId) {
const tracked = snapshot.entities.find( const tracked = snapshot.entities.find(
(e) => e.id === entry.followEntityId, (e) => e.id === entry.followEntityId,
@ -518,11 +993,14 @@ export function DemoParticleEffects({
entry.origin[1] = tracked.position[1]; entry.origin[1] = tracked.position[1];
entry.origin[2] = tracked.position[2]; entry.origin[2] = tracked.position[2];
} }
if (tracked?.direction) {
entry.emitAxis = tracked.direction;
}
} }
// Streaming emitters emit periodically. // Streaming emitters emit periodically.
if (!entry.isBurst) { if (!entry.isBurst) {
entry.emitter.emitPeriodic(entry.origin, dtMS); entry.emitter.emitPeriodic(entry.origin, dtMS, entry.emitAxis);
} }
// Advance physics and interpolation. // Advance physics and interpolation.
@ -536,6 +1014,9 @@ export function DemoParticleEffects({
entry.material.uniforms.particleTexture.value = entry.targetTexture; entry.material.uniforms.particleTexture.value = entry.targetTexture;
} }
// Reduce particle opacity in debug mode for visibility.
entry.material.uniforms.debugOpacity.value = debugMode ? 0.2 : 1.0;
// Sync GPU buffers. // Sync GPU buffers.
syncBuffers(entry); syncBuffers(entry);
@ -604,8 +1085,87 @@ export function DemoParticleEffects({
} }
} }
// ── Update explosion wireframe spheres ──
const spheres = activeExplosionSpheresRef.current;
const now = demoEffectNow();
for (let i = spheres.length - 1; i >= 0; i--) {
const sphere = spheres[i];
const elapsed = now - sphere.creationTime;
const frac = Math.min(elapsed / sphere.lifetimeMS, 1);
// Quick scale-up in first 10%, then hold.
const scaleFrac = Math.min(frac / 0.1, 1);
sphere.mesh.scale.setScalar(sphere.targetRadius * scaleFrac);
// Fade opacity over lifetime.
sphere.material.opacity = 1 - frac;
sphere.labelMaterial.opacity = 1 - frac;
// Remove when lifetime expires.
if (frac >= 1) {
group.remove(sphere.mesh);
group.remove(sphere.label);
sphere.material.dispose();
sphere.labelMaterial.dispose();
spheres.splice(i, 1);
}
}
// ── Update shockwave rings ──
const shockwaves = activeShockwavesRef.current;
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
const elapsed = now - sw.creationTime;
const t = Math.min(elapsed / sw.lifetimeMS, 1);
const dtSec = effectDelta;
// V12 expansion physics: velocity += acceleration * dt; radius += velocity * dt
sw.velocity += sw.data.acceleration * dtSec;
sw.radius += sw.velocity * dtSec;
// Interpolate color from keyframes.
const color = interpolateShockwaveColor(sw.data, t);
// Update ring geometry.
updateShockwaveGeometry(
sw.geometry,
sw.data,
sw.radius,
color,
sw.data.is2D,
);
// Update bottom ring if present.
if (sw.bottomGeometry) {
updateShockwaveGeometry(
sw.bottomGeometry,
sw.data,
sw.radius,
color,
sw.data.is2D,
);
}
// For is2D mode: billboard the ring to face the camera.
if (sw.data.is2D) {
sw.mesh.lookAt(state.camera.position);
}
// Remove when lifetime expires.
if (t >= 1) {
group.remove(sw.mesh);
if (sw.bottomMesh) group.remove(sw.bottomMesh);
sw.geometry.dispose();
sw.bottomGeometry?.dispose();
sw.material.dispose();
shockwaves.splice(i, 1);
}
}
// ── Audio: explosion impact sounds ── // ── Audio: explosion impact sounds ──
if (audioEnabled && audioLoader && audioListener && groupRef.current) { // Only process new audio events while playing to avoid triggering
// sounds during pause (existing sounds are frozen via AudioContext.suspend).
if (isPlaying && audioEnabled && audioLoader && audioListener && groupRef.current) {
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
if ( if (
entity.type !== "Explosion" || entity.type !== "Explosion" ||
@ -684,6 +1244,7 @@ export function DemoParticleEffects({
sound.setMaxDistance(resolved.maxDist); sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1); sound.setRolloffFactor(1);
sound.setVolume(resolved.volume); sound.setVolume(resolved.volume);
sound.setPlaybackRate(playbackState.rate);
sound.setLoop(true); sound.setLoop(true);
sound.position.set( sound.position.set(
entity.position![1], entity.position![1],
@ -691,6 +1252,7 @@ export function DemoParticleEffects({
entity.position![0], entity.position![0],
); );
group.add(sound); group.add(sound);
trackDemoSound(sound);
sound.play(); sound.play();
projSounds.set(entity.id, sound); projSounds.set(entity.id, sound);
}); });
@ -702,6 +1264,7 @@ export function DemoParticleEffects({
// Despawn: stop sounds for entities no longer present. // Despawn: stop sounds for entities no longer present.
for (const [entityId, sound] of projSounds) { for (const [entityId, sound] of projSounds) {
if (!currentEntityIds.has(entityId)) { if (!currentEntityIds.has(entityId)) {
untrackDemoSound(sound);
try { sound.stop(); } catch {} try { sound.stop(); } catch {}
sound.disconnect(); sound.disconnect();
groupRef.current?.remove(sound); groupRef.current?.remove(sound);
@ -768,10 +1331,32 @@ export function DemoParticleEffects({
entry.material.dispose(); entry.material.dispose();
} }
activeEmittersRef.current = []; activeEmittersRef.current = [];
// Clean up explosion spheres.
for (const sphere of activeExplosionSpheresRef.current) {
if (group) {
group.remove(sphere.mesh);
group.remove(sphere.label);
}
sphere.material.dispose();
sphere.labelMaterial.dispose();
}
activeExplosionSpheresRef.current = [];
// Clean up shockwave rings.
for (const sw of activeShockwavesRef.current) {
if (group) {
group.remove(sw.mesh);
if (sw.bottomMesh) group.remove(sw.bottomMesh);
}
sw.geometry.dispose();
sw.bottomGeometry?.dispose();
sw.material.dispose();
}
activeShockwavesRef.current = [];
processedExplosionsRef.current.clear(); processedExplosionsRef.current.clear();
trailEntitiesRef.current.clear(); trailEntitiesRef.current.clear();
// Clean up projectile sounds. // Clean up projectile sounds.
for (const [, sound] of projectileSoundsRef.current) { for (const [, sound] of projectileSoundsRef.current) {
untrackDemoSound(sound);
try { sound.stop(); } catch {} try { sound.stop(); } catch {}
sound.disconnect(); sound.disconnect();
if (group) group.remove(sound); if (group) group.remove(sound);

View file

@ -17,7 +17,7 @@ import { TickProvider } from "./TickProvider";
import { DemoEntityGroup } from "./DemoEntities"; import { DemoEntityGroup } from "./DemoEntities";
import { DemoParticleEffects } from "./DemoParticleEffects"; import { DemoParticleEffects } from "./DemoParticleEffects";
import { PlayerEyeOffset } from "./DemoPlayerModel"; import { PlayerEyeOffset } from "./DemoPlayerModel";
import { useEngineStoreApi } from "../state"; import { useEngineStoreApi, advanceEffectClock } from "../state";
import type { import type {
DemoEntity, DemoEntity,
DemoRecording, DemoRecording,
@ -67,14 +67,13 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const prevMap = entityMapRef.current; const prevMap = entityMapRef.current;
const nextMap = new Map<string, DemoEntity>(); const nextMap = new Map<string, DemoEntity>();
// Derive shouldRebuild from the entity loop itself instead of computing let shouldRebuild = false;
// an O(n) string signature every frame. Entity count change catches
// add/remove; identity check catches per-entity changes.
let shouldRebuild = snapshot.entities.length !== prevMap.size;
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
let renderEntity = prevMap.get(entity.id); let renderEntity = prevMap.get(entity.id);
if (
// Identity change → new component (unmount/remount)
const needsNewIdentity =
!renderEntity || !renderEntity ||
renderEntity.type !== entity.type || renderEntity.type !== entity.type ||
renderEntity.dataBlock !== entity.dataBlock || renderEntity.dataBlock !== entity.dataBlock ||
@ -82,8 +81,9 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
renderEntity.className !== entity.className || renderEntity.className !== entity.className ||
renderEntity.ghostIndex !== entity.ghostIndex || renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId || renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint renderEntity.shapeHint !== entity.shapeHint;
) {
if (needsNewIdentity) {
renderEntity = buildStreamDemoEntity( renderEntity = buildStreamDemoEntity(
entity.id, entity.id,
entity.type, entity.type,
@ -96,26 +96,57 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
entity.ghostIndex, entity.ghostIndex,
entity.dataBlockId, entity.dataBlockId,
entity.shapeHint, entity.shapeHint,
entity.explosionDataBlockId,
entity.faceViewer,
); );
renderEntity.playerName = entity.playerName;
renderEntity.iffColor = entity.iffColor;
renderEntity.targetRenderFlags = entity.targetRenderFlags;
renderEntity.threads = entity.threads;
renderEntity.weaponImageState = entity.weaponImageState;
renderEntity.weaponImageStates = entity.weaponImageStates;
renderEntity.headPitch = entity.headPitch;
renderEntity.headYaw = entity.headYaw;
renderEntity.direction = entity.direction;
renderEntity.visual = entity.visual;
renderEntity.explosionDataBlockId = entity.explosionDataBlockId;
renderEntity.faceViewer = entity.faceViewer;
renderEntity.spawnTime = snapshot.timeSec;
shouldRebuild = true;
} else if (
renderEntity.playerName !== entity.playerName ||
renderEntity.iffColor !== entity.iffColor ||
renderEntity.targetRenderFlags !== entity.targetRenderFlags ||
renderEntity.threads !== entity.threads ||
renderEntity.weaponImageState !== entity.weaponImageState ||
renderEntity.weaponImageStates !== entity.weaponImageStates ||
renderEntity.headPitch !== entity.headPitch ||
renderEntity.headYaw !== entity.headYaw ||
renderEntity.direction !== entity.direction ||
renderEntity.visual !== entity.visual
) {
// Render-affecting field changed → new object so React.memo sees
// a different reference and re-renders this entity's component.
renderEntity = {
...renderEntity,
playerName: entity.playerName,
iffColor: entity.iffColor,
targetRenderFlags: entity.targetRenderFlags,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
direction: entity.direction,
visual: entity.visual,
};
shouldRebuild = true; shouldRebuild = true;
} }
// else: no render-affecting changes, keep same object reference
// so React.memo can skip re-rendering this entity.
renderEntity.playerName = entity.playerName; // Keyframe update (mutable — only used as fallback position for
renderEntity.iffColor = entity.iffColor; // retained explosion entities; useFrame reads from snapshot entities).
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;
renderEntity.threads = entity.threads;
renderEntity.weaponImageState = entity.weaponImageState;
renderEntity.weaponImageStates = entity.weaponImageStates;
renderEntity.headPitch = entity.headPitch;
renderEntity.headYaw = entity.headYaw;
if (renderEntity.keyframes.length === 0) { if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({ renderEntity.keyframes.push({
time: snapshot.timeSec, time: snapshot.timeSec,
@ -123,7 +154,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
rotation: entity.rotation ?? [0, 0, 0, 1], rotation: entity.rotation ?? [0, 0, 0, 1],
}); });
} }
const kf = renderEntity.keyframes[0]; const kf = renderEntity.keyframes[0];
kf.time = snapshot.timeSec; kf.time = snapshot.timeSec;
if (entity.position) kf.position = entity.position; if (entity.position) kf.position = entity.position;
@ -138,6 +168,28 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
nextMap.set(entity.id, renderEntity); nextMap.set(entity.id, renderEntity);
} }
// Retain explosion entities with DTS shapes after they leave the snapshot.
// These entities are ephemeral (~1 tick) but the visual effect lasts seconds.
for (const [id, entity] of prevMap) {
if (nextMap.has(id)) continue;
if (
entity.type === "Explosion" &&
entity.dataBlock &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
if (age < 5) {
nextMap.set(id, entity);
continue;
}
}
// Entity removed (or retention expired).
shouldRebuild = true;
}
// Detect new entities added.
if (nextMap.size !== prevMap.size) shouldRebuild = true;
entityMapRef.current = nextMap; entityMapRef.current = nextMap;
if (shouldRebuild) { if (shouldRebuild) {
setEntities(Array.from(nextMap.values())); setEntities(Array.from(nextMap.values()));
@ -209,7 +261,10 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
playbackClockRef.current = requestedTimeSec; playbackClockRef.current = requestedTimeSec;
} }
// Advance the shared effect clock so all effect timers (particles,
// explosions, shockwaves, shape animations) respect pause and rate.
if (isPlaying) { if (isPlaying) {
advanceEffectClock(delta, playback.rate);
playbackClockRef.current += delta * playback.rate; playbackClockRef.current += delta * playback.rate;
} }
@ -269,7 +324,12 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
renderCurrent.camera?.orbitTargetId !== renderCurrent.camera?.orbitTargetId !==
publishedSnapshot.camera?.orbitTargetId || publishedSnapshot.camera?.orbitTargetId ||
renderCurrent.chatMessages.length !== publishedSnapshot.chatMessages.length || renderCurrent.chatMessages.length !== publishedSnapshot.chatMessages.length ||
renderCurrent.teamScores !== publishedSnapshot.teamScores; renderCurrent.teamScores.length !== publishedSnapshot.teamScores.length ||
renderCurrent.teamScores.some(
(ts, i) =>
ts.score !== publishedSnapshot.teamScores[i]?.score ||
ts.playerCount !== publishedSnapshot.teamScores[i]?.playerCount,
);
if (shouldPublish) { if (shouldPublish) {
publishedSnapshotRef.current = renderCurrent; publishedSnapshotRef.current = renderCurrent;
@ -335,10 +395,23 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const currentEntities = getEntityMap(renderCurrent); const currentEntities = getEntityMap(renderCurrent);
const previousEntities = getEntityMap(renderPrev); const previousEntities = getEntityMap(renderPrev);
const renderEntities = entityMapRef.current;
const root = rootRef.current; const root = rootRef.current;
if (root) { if (root) {
for (const child of root.children) { for (const child of root.children) {
const entity = currentEntities.get(child.name); let entity = currentEntities.get(child.name);
// Retained entities (e.g. explosion shapes kept alive past their
// snapshot lifetime) won't be in the snapshot entity map. Fall back
// to their last-known keyframe position from the render entity.
if (!entity) {
const renderEntity = renderEntities.get(child.name);
if (renderEntity?.keyframes[0]?.position) {
const kf = renderEntity.keyframes[0];
child.visible = true;
child.position.set(kf.position[1], kf.position[2], kf.position[0]);
continue;
}
}
if (!entity?.position) { if (!entity?.position) {
child.visible = false; child.visible = false;
continue; continue;
@ -448,7 +521,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
<TickProvider> <TickProvider>
<group ref={rootRef}> <group ref={rootRef}>
{entities.map((entity) => ( {entities.map((entity) => (
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} /> <DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} playback={recording.streamingPlayback} />
))} ))}
</group> </group>
<DemoParticleEffects <DemoParticleEffects

View file

@ -33,12 +33,27 @@ import {
resolveAudioProfile, resolveAudioProfile,
playOneShotSound, playOneShotSound,
getCachedAudioBuffer, getCachedAudioBuffer,
trackDemoSound,
untrackDemoSound,
} from "./AudioEmitter"; } from "./AudioEmitter";
import { audioToUrl } from "../loaders"; import { audioToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { useEngineStoreApi, useEngineSelector } from "../state"; import { useEngineStoreApi, useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types"; import type { DemoEntity } from "../demo/types";
/**
* Map weapon shape to the arm blend animation (armThread).
* Only missile launcher and sniper rifle have custom arm poses; all others
* use the default `lookde`.
*/
function getArmThread(weaponShape: string | undefined): string {
if (!weaponShape) return "lookde";
const lower = weaponShape.toLowerCase();
if (lower.includes("missile")) return "lookms";
if (lower.includes("sniper")) return "looksn";
return "lookde";
}
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */ /** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
function stopLoopingSound( function stopLoopingSound(
soundRef: React.MutableRefObject<PositionalAudio | null>, soundRef: React.MutableRefObject<PositionalAudio | null>,
@ -47,6 +62,7 @@ function stopLoopingSound(
) { ) {
const sound = soundRef.current; const sound = soundRef.current;
if (!sound) return; if (!sound) return;
untrackDemoSound(sound);
try { sound.stop(); } catch {} try { sound.stop(); } catch {}
sound.disconnect(); sound.disconnect();
parent?.remove(sound); parent?.remove(sound);
@ -105,10 +121,12 @@ export function DemoPlayerModel({
// Build case-insensitive clip lookup with alias support. // Build case-insensitive clip lookup with alias support.
const animActionsRef = useRef(new Map<string, AnimationAction>()); const animActionsRef = useRef(new Map<string, AnimationAction>());
const blendActionsRef = useRef<{ const blendActionsRef = useRef<{
look: AnimationAction | null;
head: AnimationAction | null; head: AnimationAction | null;
headside: AnimationAction | null; headside: AnimationAction | null;
}>({ look: null, head: null, headside: null }); }>({ head: null, headside: null });
// Arm pose blend actions keyed by animation name (lookde, lookms, looksn).
const armActionsRef = useRef(new Map<string, AnimationAction>());
const activeArmRef = useRef<string | null>(null);
const currentAnimRef = useRef({ name: "root", timeScale: 1 }); const currentAnimRef = useRef({ name: "root", timeScale: 1 });
const isDeadRef = useRef(false); const isDeadRef = useRef(false);
@ -126,20 +144,18 @@ export function DemoPlayerModel({
// Set up additive blend animations for aim/head articulation. // Set up additive blend animations for aim/head articulation.
// These clips must be cloned before makeClipAdditive (which mutates in // These clips must be cloned before makeClipAdditive (which mutates in
// place) since multiple player entities share the same GLTF cache. // place) since multiple player entities share the same GLTF cache.
const blendNames: Array<{ key: keyof typeof blendActionsRef.current; names: string[] }> = [
{ key: "look", names: ["lookde", "look"] }, // Head blend actions.
{ key: "head", names: ["head"] }, const blendRefs: typeof blendActionsRef.current = { head: null, headside: null };
{ key: "headside", names: ["headside"] }, for (const { key, names } of [
]; { key: "head" as const, names: ["head"] },
const blendRefs: typeof blendActionsRef.current = { look: null, head: null, headside: null }; { key: "headside" as const, names: ["headside"] },
for (const { key, names } of blendNames) { ]) {
const clip = gltf.animations.find((c) => const clip = gltf.animations.find((c) =>
names.includes(c.name.toLowerCase()), names.includes(c.name.toLowerCase()),
); );
if (!clip) continue; if (!clip) continue;
const cloned = clip.clone(); const cloned = clip.clone();
// Reference frame at clip midpoint = neutral pose. The second arg is a
// frame index (not time), so convert via fps.
const fps = 30; const fps = 30;
const neutralFrame = Math.round((clip.duration * fps) / 2); const neutralFrame = Math.round((clip.duration * fps) / 2);
AnimationUtils.makeClipAdditive(cloned, neutralFrame, clip, fps); AnimationUtils.makeClipAdditive(cloned, neutralFrame, clip, fps);
@ -152,13 +168,53 @@ export function DemoPlayerModel({
} }
blendActionsRef.current = blendRefs; blendActionsRef.current = blendRefs;
// Arm pose blend actions: create one per available arm animation so we
// can switch between them when the equipped weapon changes.
// All arm clips use the lookde midpoint as the additive reference, so
// switching from lookde to lookms captures the shoulder repositioning.
const armActions = new Map<string, AnimationAction>();
const lookdeClip = gltf.animations.find(
(c) => c.name.toLowerCase() === "lookde",
);
const fps = 30;
const lookdeRefFrame = lookdeClip
? Math.round((lookdeClip.duration * fps) / 2)
: 0;
for (const armName of ["lookde", "lookms", "looksn"]) {
const clip = gltf.animations.find(
(c) => c.name.toLowerCase() === armName,
);
if (!clip) continue;
const cloned = clip.clone();
// Use lookde's midpoint as reference for all arm clips so that
// lookms/looksn capture the absolute shoulder offset.
const refClip = lookdeClip ?? clip;
AnimationUtils.makeClipAdditive(cloned, lookdeRefFrame, refClip, fps);
const action = mixer.clipAction(cloned);
action.blendMode = AdditiveAnimationBlendMode;
action.timeScale = 0;
action.weight = 0;
action.play();
armActions.set(armName, action);
}
armActionsRef.current = armActions;
// Start with default arm pose.
const defaultArm = armActions.get("lookde");
if (defaultArm) {
defaultArm.weight = 1;
activeArmRef.current = "lookde";
}
// Force initial pose evaluation. // Force initial pose evaluation.
mixer.update(0); mixer.update(0);
return () => { return () => {
mixer.stopAllAction(); mixer.stopAllAction();
animActionsRef.current = new Map(); animActionsRef.current = new Map();
blendActionsRef.current = { look: null, head: null, headside: null }; blendActionsRef.current = { head: null, headside: null };
armActionsRef.current = new Map();
activeArmRef.current = null;
}; };
}, [mixer, gltf.animations, shapeAliases]); }, [mixer, gltf.animations, shapeAliases]);
@ -252,8 +308,26 @@ export function DemoPlayerModel({
} }
} }
// Switch arm blend animation based on equipped weapon.
const desiredArm = getArmThread(entity.weaponShape);
if (desiredArm !== activeArmRef.current) {
const armActions = armActionsRef.current;
const prev = activeArmRef.current
? armActions.get(activeArmRef.current)
: null;
const next = armActions.get(desiredArm);
if (next) {
if (prev) prev.weight = 0;
next.weight = isDead ? 0 : 1;
activeArmRef.current = desiredArm;
}
}
// Drive additive blend animations for aim/head articulation. // Drive additive blend animations for aim/head articulation.
const { look, head, headside } = blendActionsRef.current; const { head, headside } = blendActionsRef.current;
const armAction = activeArmRef.current
? armActionsRef.current.get(activeArmRef.current)
: null;
const blendWeight = isDead ? 0 : 1; const blendWeight = isDead ? 0 : 1;
const headPitch = entity.headPitch ?? 0; const headPitch = entity.headPitch ?? 0;
@ -261,9 +335,9 @@ export function DemoPlayerModel({
const pitchPos = (headPitch + 1) / 2; const pitchPos = (headPitch + 1) / 2;
const yawPos = (headYaw + 1) / 2; const yawPos = (headYaw + 1) / 2;
if (look) { if (armAction) {
look.time = pitchPos * look.getClip().duration; armAction.time = pitchPos * armAction.getClip().duration;
look.weight = blendWeight; armAction.weight = blendWeight;
} }
if (head) { if (head) {
head.time = pitchPos * head.getClip().duration; head.time = pitchPos * head.getClip().duration;
@ -545,8 +619,10 @@ function AnimatedWeaponModel({
sound.setMaxDistance(resolved.maxDist); sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1); sound.setRolloffFactor(1);
sound.setVolume(resolved.volume); sound.setVolume(resolved.volume);
sound.setPlaybackRate(playback.rate);
sound.setLoop(true); sound.setLoop(true);
weaponClone.add(sound); weaponClone.add(sound);
trackDemoSound(sound);
sound.play(); sound.play();
loopingSoundRef.current = sound; loopingSoundRef.current = sound;
loopingSoundStateRef.current = currentIdx; loopingSoundStateRef.current = currentIdx;

View file

@ -1,17 +1,34 @@
import { useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Quaternion, Vector3 } from "three"; import { useFrame } from "@react-three/fiber";
import {
AnimationMixer,
LoopOnce,
Quaternion,
Vector3,
} from "three";
import type { Group, Material } from "three";
import { demoEffectNow, engineStore } from "../state";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { import {
_r90, _r90,
_r90inv, _r90inv,
getPosedNodeTransform, getPosedNodeTransform,
processShapeScene,
} from "../demo/demoPlaybackUtils"; } from "../demo/demoPlaybackUtils";
import {
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { import {
ShapeRenderer, ShapeRenderer,
useStaticShape, useStaticShape,
} from "./GenericShape"; } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider"; import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript"; import type { TorqueObject } from "../torqueScript";
import type { DemoThreadState } from "../demo/types"; import type { DemoThreadState, DemoStreamEntity } from "../demo/types";
import type { DemoStreamingPlayback } from "../demo/types";
/** Renders a shape model for a demo entity using the existing shape pipeline. */ /** Renders a shape model for a demo entity using the existing shape pipeline. */
export function DemoShapeModel({ export function DemoShapeModel({
@ -43,13 +60,27 @@ export function DemoShapeModel({
); );
} }
/**
* Map weapon shape to the arm blend animation (armThread).
* Only missile launcher and sniper rifle have custom arm poses; all others
* use the default `lookde`.
*/
function getArmThread(weaponShape: string | undefined): string {
if (!weaponShape) return "lookde";
const lower = weaponShape.toLowerCase();
if (lower.includes("missile")) return "lookms";
if (lower.includes("sniper")) return "looksn";
return "lookde";
}
/** /**
* Renders a mounted weapon using the Torque engine's mount system. * Renders a mounted weapon using the Torque engine's mount system.
* *
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node * 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" * (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 * animation) pose, with the weapon-specific arm animation applied additively.
* rotation: T_mount = R90 * M0 * MP^(-1) * R90^(-1). * The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
*/ */
export function DemoWeaponModel({ export function DemoWeaponModel({
shapeName, shapeName,
@ -62,11 +93,13 @@ export function DemoWeaponModel({
const weaponGltf = useStaticShape(shapeName); const weaponGltf = useStaticShape(shapeName);
const mountTransform = useMemo(() => { const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed (Root animation) skeleton. // Get Mount0 from the player's posed skeleton with arm animation applied.
const armThread = getArmThread(shapeName);
const m0 = getPosedNodeTransform( const m0 = getPosedNodeTransform(
playerGltf.scene, playerGltf.scene,
playerGltf.animations, playerGltf.animations,
"Mount0", "Mount0",
[armThread],
); );
if (!m0) return { position: undefined, quaternion: undefined }; if (!m0) return { position: undefined, quaternion: undefined };
@ -126,3 +159,322 @@ export function DemoWeaponModel({
</ShapeInfoProvider> </ShapeInfoProvider>
); );
} }
// ── Explosion shape rendering ──
//
// Explosion DTS shapes are flat billboard planes with IFL-animated textures,
// vis-keyframed opacity, and size keyframe interpolation. They use
// useStaticShape (shared GLTF cache via drei's useGLTF) but render directly
// rather than through ShapeRenderer, because:
// - faceViewer billboarding needs to control the shape orientation
// - ShapeModel's fixed 90° Y rotation conflicts with billboard orientation
// - Explosion shapes need LoopOnce animation, not the deploy/ambient lifecycle
interface VisNode {
mesh: any;
keyframes: number[];
duration: number;
cyclic: boolean;
}
interface IflInfo {
mesh: any;
iflPath: string;
sequenceName?: string;
duration?: number;
cyclic?: boolean;
toolBegin?: number;
}
function extractSizeKeyframes(expBlock: Record<string, unknown>): {
times: number[];
sizes: [number, number, number][];
} {
const rawSizes = expBlock.sizes as
| Array<{ x: number; y: number; z: number }>
| undefined;
const rawTimes = expBlock.times as number[] | undefined;
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
return { times: [0, 1], sizes: [[1, 1, 1], [1, 1, 1]] };
}
// sizes are packed as value*100 integers on the wire; divide by 100.
const sizes: [number, number, number][] = rawSizes.map((s) => [
s.x / 100,
s.y / 100,
s.z / 100,
]);
// times are written via writeFloat(8) and are already [0,1] floats.
const times = Array.isArray(rawTimes)
? rawTimes
: sizes.map((_, i) => i / Math.max(sizes.length - 1, 1));
return { times, sizes };
}
function interpolateSize(
keyframes: { times: number[]; sizes: [number, number, number][] },
t: number,
): [number, number, number] {
const { times, sizes } = keyframes;
if (times.length === 0) return [1, 1, 1];
if (t <= times[0]) return sizes[0];
if (t >= times[times.length - 1]) return sizes[sizes.length - 1];
for (let i = 0; i < times.length - 1; i++) {
if (t >= times[i] && t <= times[i + 1]) {
const frac = (t - times[i]) / (times[i + 1] - times[i]);
return [
sizes[i][0] + (sizes[i + 1][0] - sizes[i][0]) * frac,
sizes[i][1] + (sizes[i + 1][1] - sizes[i][1]) * frac,
sizes[i][2] + (sizes[i + 1][2] - sizes[i][2]) * frac,
];
}
}
return sizes[sizes.length - 1];
}
/**
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
*/
export function DemoExplosionShape({
entity,
playback,
}: {
entity: DemoStreamEntity;
playback: DemoStreamingPlayback;
}) {
const gltf = useStaticShape(entity.dataBlock!);
const groupRef = useRef<Group>(null);
const startTimeRef = useRef(demoEffectNow());
const randAngleRef = useRef(Math.random() * Math.PI * 2);
const iflAtlasesRef = useRef<Array<{ atlas: IflAtlas; info: IflInfo }>>([]);
const expBlock = useMemo(() => {
if (!entity.explosionDataBlockId) return undefined;
return playback.getDataBlockData(entity.explosionDataBlockId);
}, [entity.explosionDataBlockId, playback]);
const sizeKeyframes = useMemo(
() => (expBlock ? extractSizeKeyframes(expBlock) : undefined),
[expBlock],
);
const baseScale = useMemo<[number, number, number]>(() => {
const explosionScale = expBlock?.explosionScale as
| { x: number; y: number; z: number }
| undefined;
return explosionScale
? [explosionScale.x / 100, explosionScale.y / 100, explosionScale.z / 100]
: [1, 1, 1];
}, [expBlock]);
// lifetimeMS is packed as value >> 5 (ticks); recover with << 5 (× 32).
const lifetimeTicks = (expBlock?.lifetimeMS as number) ?? 31;
const lifetimeMS = lifetimeTicks * 32;
const faceViewer = entity.faceViewer !== false;
// Clone scene, process materials, collect vis nodes and IFL info.
const { scene, mixer, visNodes, iflInfos, materials } = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
// Collect IFL info BEFORE processShapeScene replaces materials.
const iflInfos: IflInfo[] = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material) ? node.material[0] : node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
sequenceName: ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined,
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
toolBegin: ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
});
}
});
processShapeScene(scene);
// Collect vis-animated nodes keyed by sequence name.
const visNodes: VisNode[] = [];
scene.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
if (!ud) return;
const kf = ud.vis_keyframes;
const dur = ud.vis_duration;
const seqName = (ud.vis_sequence ?? "").toLowerCase();
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
return;
// Only include vis nodes tied to the "ambient" sequence.
if (seqName === "ambient") {
visNodes.push({ mesh: node, keyframes: kf, duration: dur, cyclic: !!ud.vis_cyclic });
}
});
// Activate vis nodes: make visible, ensure transparent material.
for (const v of visNodes) {
v.mesh.visible = true;
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.transparent = true;
v.mesh.material.depthWrite = false;
}
}
// Also un-hide IFL meshes that don't have vis_sequence (always visible).
for (const info of iflInfos) {
if (!info.mesh.userData?.vis_sequence) {
info.mesh.visible = true;
}
}
// Set up animation mixer with the ambient clip (LoopOnce).
const clips = new Map<string, any>();
for (const clip of gltf.animations) {
clips.set(clip.name.toLowerCase(), clip);
}
const ambientClip = clips.get("ambient");
let mixer: AnimationMixer | null = null;
if (ambientClip) {
mixer = new AnimationMixer(scene);
const action = mixer.clipAction(ambientClip);
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
// playSpeed is packed as value*20 on the wire; divide by 20.
const playSpeed = ((expBlock?.playSpeed as number) ?? 20) / 20;
action.timeScale = playSpeed;
action.play();
}
// Collect all materials for fade-out.
const materials: Material[] = [];
scene.traverse((child: any) => {
if (!child.isMesh) return;
if (Array.isArray(child.material)) {
materials.push(...child.material);
} else if (child.material) {
materials.push(child.material);
}
});
// Disable frustum culling (explosion may scale beyond bounds).
scene.traverse((child) => { child.frustumCulled = false; });
return { scene, mixer, visNodes, iflInfos, materials };
}, [gltf, expBlock]);
// Load IFL texture atlases.
useEffect(() => {
iflAtlasesRef.current = [];
for (const info of iflInfos) {
loadIflAtlas(info.iflPath)
.then((atlas) => {
const mat = Array.isArray(info.mesh.material)
? info.mesh.material[0]
: info.mesh.material;
if (mat) {
mat.map = atlas.texture;
mat.needsUpdate = true;
}
iflAtlasesRef.current.push({ atlas, info });
})
.catch(() => {});
}
}, [iflInfos]);
useFrame((state, delta) => {
const group = groupRef.current;
if (!group) return;
const playbackState = engineStore.getState().playback;
const effectDelta = playbackState.status === "playing"
? delta * playbackState.rate : 0;
const elapsed = demoEffectNow() - startTimeRef.current;
const t = Math.min(elapsed / lifetimeMS, 1);
const elapsedSec = elapsed / 1000;
// Advance skeleton animation.
if (mixer) {
mixer.update(effectDelta);
}
// Fade multiplier for the last 20% of lifetime.
const fadeAlpha = t > 0.8 ? 1 - (t - 0.8) / 0.2 : 1;
// Drive vis opacity animation.
for (const { mesh, keyframes, duration, cyclic } of visNodes) {
const mat = mesh.material;
if (!mat || Array.isArray(mat)) continue;
const rawT = elapsedSec / duration;
const vt = cyclic ? rawT % 1 : Math.min(rawT, 1);
const n = keyframes.length;
const pos = vt * n;
const lo = Math.floor(pos) % n;
const hi = (lo + 1) % n;
const frac = pos - Math.floor(pos);
const visOpacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
mat.opacity = visOpacity * fadeAlpha;
}
// Also fade non-vis materials.
if (fadeAlpha < 1) {
for (const mat of materials) {
if ("opacity" in mat) {
mat.transparent = true;
(mat as any).opacity *= fadeAlpha;
}
}
}
// Advance IFL texture atlases.
for (const { atlas, info } of iflAtlasesRef.current) {
let iflTime: number;
if (info.sequenceName && info.duration) {
const pos = info.cyclic
? (elapsedSec / info.duration) % 1
: Math.min(elapsedSec / info.duration, 1);
iflTime = pos * info.duration + (info.toolBegin ?? 0);
} else {
iflTime = elapsedSec;
}
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, iflTime));
}
// Size keyframe interpolation.
if (sizeKeyframes) {
const size = interpolateSize(sizeKeyframes, t);
group.scale.set(
size[0] * baseScale[0],
size[1] * baseScale[1],
size[2] * baseScale[2],
);
}
// faceViewer: billboard toward camera with random Z rotation.
if (faceViewer) {
group.lookAt(state.camera.position);
group.rotateZ(randAngleRef.current);
}
});
return (
<group ref={groupRef}>
{/* Flip 180° around Y so the face (GLB +Z normal) points toward the
camera after the parent group's lookAt (which aims -Z at camera). */}
<group rotation={[0, Math.PI, 0]}>
<primitive object={scene} />
</group>
</group>
);
}

View file

@ -0,0 +1,32 @@
.Root {
pointer-events: none;
display: inline-flex;
flex-direction: column;
align-items: center;
white-space: nowrap;
gap: 1px;
}
.Distance {
color: #fff;
font-size: 10px;
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 1px rgba(0, 0, 0, 0.7);
opacity: 0.5;
}
.Icon {
width: 16px;
height: 16px;
image-rendering: pixelated;
opacity: 0.5;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
mask-image: var(--flag-icon-url);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-image: var(--flag-icon-url);
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}

View file

@ -0,0 +1,67 @@
import { useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Vector3 } from "three";
import { textureToUrl } from "../loaders";
import type { DemoEntity } from "../demo/types";
import styles from "./FlagMarker.module.css";
const FLAG_ICON_HEIGHT = 1.5;
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
const _tmpVec = new Vector3();
/**
* Floating flag icon above a flag entity, tinted by IFF color (green for
* friendly, red for enemy matching Tribes 2's sensor group color system).
* Always visible regardless of distance.
*/
export function FlagMarker({
entity,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const markerRef = useRef<Group>(null);
const iconRef = useRef<HTMLDivElement>(null);
const distRef = useRef<HTMLSpanElement>(null);
const { camera } = useThree();
useFrame(() => {
// Tint imperatively — iffColor is mutated in-place by streaming playback.
if (iconRef.current && entity.iffColor) {
const { r, g, b } = entity.iffColor;
iconRef.current.style.backgroundColor = `rgb(${r},${g},${b})`;
}
// Update distance label.
if (distRef.current && markerRef.current) {
markerRef.current.getWorldPosition(_tmpVec);
const distance = camera.position.distanceTo(_tmpVec);
distRef.current.textContent = distance.toFixed(1);
}
});
const initialColor = entity.iffColor
? `rgb(${entity.iffColor.r},${entity.iffColor.g},${entity.iffColor.b})`
: "rgb(200,200,200)";
return (
<group ref={markerRef}>
<Html position={[0, FLAG_ICON_HEIGHT, 0]} center>
<div className={styles.Root}>
<span ref={distRef} className={styles.Distance} />
<div
ref={iconRef}
className={styles.Icon}
style={{
backgroundColor: initialColor,
"--flag-icon-url": `url(${FLAG_ICON_URL})`,
} as React.CSSProperties}
/>
</div>
</Html>
</group>
);
}

View file

@ -21,7 +21,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils"; import { setupTexture } from "../textureUtils";
import { useDebug, useSettings } from "./SettingsProvider"; import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider"; import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector } from "../state"; import { useEngineSelector, demoEffectNow, engineStore } from "../state";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { import {
useIflTexture, useIflTexture,
@ -39,6 +39,14 @@ import {
} from "../demo/demoPlaybackUtils"; } from "../demo/demoPlaybackUtils";
import type { DemoThreadState } from "../demo/types"; import type { DemoThreadState } from "../demo/types";
/** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number {
const status = engineStore.getState().playback.status;
return status !== "stopped"
? demoEffectNow() / 1000
: performance.now() / 1000;
}
/** Shared props for texture rendering components */ /** Shared props for texture rendering components */
interface TextureProps { interface TextureProps {
material: MeshStandardMaterial; material: MeshStandardMaterial;
@ -687,7 +695,7 @@ export const ShapeModel = memo(function ShapeModel({
const vNodes = visNodesBySequence.get(seqLower); const vNodes = visNodesBySequence.get(seqLower);
const thread: ThreadState = { const thread: ThreadState = {
sequence: seqLower, sequence: seqLower,
startTime: performance.now() / 1000, startTime: shapeNowSec(),
}; };
if (clip && mixer) { if (clip && mixer) {
@ -773,7 +781,7 @@ export const ShapeModel = memo(function ShapeModel({
for (const seqName of autoPlaySequences) { for (const seqName of autoPlaySequences) {
const vNodes = visNodesBySequence.get(seqName); const vNodes = visNodesBySequence.get(seqName);
if (vNodes) { if (vNodes) {
const startTime = performance.now() / 1000; const startTime = shapeNowSec();
for (const v of vNodes) prepareVisNode(v); for (const v of vNodes) prepareVisNode(v);
const slot = seqName === "power" ? 0 : 1; const slot = seqName === "power" ? 0 : 1;
threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime }); threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime });
@ -791,7 +799,7 @@ export const ShapeModel = memo(function ShapeModel({
threads.set(slot, { threads.set(slot, {
sequence: seqName, sequence: seqName,
action, action,
startTime: performance.now() / 1000, startTime: shapeNowSec(),
}); });
} }
} }
@ -893,6 +901,13 @@ export const ShapeModel = memo(function ShapeModel({
useFrame((_, delta) => { useFrame((_, delta) => {
const threads = threadsRef.current; const threads = threadsRef.current;
// In demo mode, scale animation by playback rate; freeze when paused.
const inDemo = demoThreadsRef.current != null;
const playbackState = engineStore.getState().playback;
const effectDelta = !inDemo ? delta
: playbackState.status === "playing" ? delta * playbackState.rate
: 0;
// React to demo thread state changes. The ghost ThreadMask data tells us // React to demo thread state changes. The ghost ThreadMask data tells us
// exactly which DTS sequences are playing/stopped on each of 4 thread slots. // exactly which DTS sequences are playing/stopped on each of 4 thread slots.
const currentDemoThreads = demoThreadsRef.current; const currentDemoThreads = demoThreadsRef.current;
@ -976,7 +991,7 @@ export const ShapeModel = memo(function ShapeModel({
} }
if (animationEnabled) { if (animationEnabled) {
mixer.update(delta); mixer.update(effectDelta);
} }
} }
@ -993,7 +1008,7 @@ export const ShapeModel = memo(function ShapeModel({
continue; continue;
} }
const elapsed = performance.now() / 1000 - thread.startTime; const elapsed = shapeNowSec() - thread.startTime;
const t = cyclic const t = cyclic
? (elapsed % duration) / duration ? (elapsed % duration) / duration
: Math.min(elapsed / duration, 1); : Math.min(elapsed / duration, 1);
@ -1016,7 +1031,7 @@ export const ShapeModel = memo(function ShapeModel({
// with the desired frames (e.g. skipping a long "off" period). // with the desired frames (e.g. skipping a long "off" period).
const iflAnimInfos = iflAnimInfosRef.current; const iflAnimInfos = iflAnimInfosRef.current;
if (iflAnimInfos.length > 0) { if (iflAnimInfos.length > 0) {
iflTimeRef.current += delta; iflTimeRef.current += effectDelta;
for (const info of iflAnimInfos) { for (const info of iflAnimInfos) {
if (!animationEnabled) { if (!animationEnabled) {
updateAtlasFrame(info.atlas, 0); updateAtlasFrame(info.atlas, 0);
@ -1029,7 +1044,7 @@ export const ShapeModel = memo(function ShapeModel({
let iflTime = 0; let iflTime = 0;
for (const [, thread] of threads) { for (const [, thread] of threads) {
if (thread.sequence === info.sequenceName) { if (thread.sequence === info.sequenceName) {
const elapsed = performance.now() / 1000 - thread.startTime; const elapsed = shapeNowSec() - thread.startTime;
const dur = info.sequenceDuration; const dur = info.sequenceDuration;
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1] // Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
const pos = info.cyclic const pos = info.cyclic

View file

@ -5,12 +5,12 @@ import {
type TouchMode, type TouchMode,
} from "./SettingsProvider"; } from "./SettingsProvider";
import { MissionSelect } from "./MissionSelect"; import { MissionSelect } from "./MissionSelect";
import { RefObject, useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, RefObject } from "react";
import { Camera } from "three";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton"; import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { LoadDemoButton } from "./LoadDemoButton"; import { LoadDemoButton } from "./LoadDemoButton";
import { useDemoRecording } from "./DemoProvider"; import { useDemoRecording } from "./DemoProvider";
import { FiInfo, FiSettings } from "react-icons/fi"; import { FiInfo, FiSettings } from "react-icons/fi";
import { Camera } from "three";
import styles from "./InspectorControls.module.css"; import styles from "./InspectorControls.module.css";
export function InspectorControls({ export function InspectorControls({
@ -18,8 +18,8 @@ export function InspectorControls({
missionType, missionType,
onChangeMission, onChangeMission,
onOpenMapInfo, onOpenMapInfo,
cameraRef,
isTouch, isTouch,
cameraRef,
}: { }: {
missionName: string; missionName: string;
missionType: string; missionType: string;
@ -31,8 +31,8 @@ export function InspectorControls({
missionType: string; missionType: string;
}) => void; }) => void;
onOpenMapInfo: () => void; onOpenMapInfo: () => void;
cameraRef: RefObject<Camera | null>;
isTouch: boolean | null; isTouch: boolean | null;
cameraRef: RefObject<Camera>;
}) { }) {
const { const {
fogEnabled, fogEnabled,
@ -117,9 +117,9 @@ export function InspectorControls({
> >
<div className={styles.Group}> <div className={styles.Group}>
<CopyCoordinatesButton <CopyCoordinatesButton
cameraRef={cameraRef}
missionName={missionName} missionName={missionName}
missionType={missionType} missionType={missionType}
cameraRef={cameraRef}
/> />
<LoadDemoButton /> <LoadDemoButton />
<button <button
@ -181,35 +181,39 @@ export function InspectorControls({
</div> </div>
</div> </div>
<div className={styles.Group}> <div className={styles.Group}>
<div className={styles.Field}> {isDemoLoaded ? null : (
<label htmlFor="fovInput">FOV</label> <div className={styles.Field}>
<input <label htmlFor="fovInput">FOV</label>
id="fovInput" <input
type="range" id="fovInput"
min={75} type="range"
max={120} min={75}
step={5} max={120}
value={fov} step={5}
disabled={isDemoLoaded} value={fov}
onChange={(event) => setFov(parseInt(event.target.value))} disabled={isDemoLoaded}
/> onChange={(event) => setFov(parseInt(event.target.value))}
<output htmlFor="fovInput">{fov}</output> />
</div> <output htmlFor="fovInput">{fov}</output>
<div className={styles.Field}> </div>
<label htmlFor="speedInput">Speed</label> )}
<input {isDemoLoaded ? null : (
id="speedInput" <div className={styles.Field}>
type="range" <label htmlFor="speedInput">Speed</label>
min={0.1} <input
max={5} id="speedInput"
step={0.05} type="range"
value={speedMultiplier} min={0.1}
disabled={isDemoLoaded} max={5}
onChange={(event) => step={0.05}
setSpeedMultiplier(parseFloat(event.target.value)) value={speedMultiplier}
} disabled={isDemoLoaded}
/> onChange={(event) =>
</div> setSpeedMultiplier(parseFloat(event.target.value))
}
/>
</div>
)}
</div> </div>
{isTouch && ( {isTouch && (
<div className={styles.Group}> <div className={styles.Group}>

View file

@ -9,12 +9,9 @@ import type {
WeaponsHudSlot, WeaponsHudSlot,
} from "../demo/types"; } from "../demo/types";
import styles from "./PlayerHUD.module.css"; import styles from "./PlayerHUD.module.css";
// ── Compass ── // ── Compass ──
const COMPASS_URL = textureToUrl("gui/hud_new_compass"); const COMPASS_URL = textureToUrl("gui/hud_new_compass");
const NSEW_URL = textureToUrl("gui/hud_new_NSEW"); const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
function Compass({ yaw }: { yaw: number | undefined }) { function Compass({ yaw }: { yaw: number | undefined }) {
if (yaw == null) return null; if (yaw == null) return null;
// The ring notch is the fixed heading indicator (always "forward" at top). // The ring notch is the fixed heading indicator (always "forward" at top).
@ -34,9 +31,7 @@ function Compass({ yaw }: { yaw: number | undefined }) {
</div> </div>
); );
} }
// ── Health / Energy bars ── // ── Health / Energy bars ──
function HealthBar({ value }: { value: number }) { function HealthBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100)); const pct = Math.max(0, Math.min(100, value * 100));
return ( return (
@ -45,7 +40,6 @@ function HealthBar({ value }: { value: number }) {
</div> </div>
); );
} }
function EnergyBar({ value }: { value: number }) { function EnergyBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100)); const pct = Math.max(0, Math.min(100, value * 100));
return ( return (
@ -54,20 +48,16 @@ function EnergyBar({ value }: { value: number }) {
</div> </div>
); );
} }
// ── Reticle ── // ── Reticle ──
const RETICLE_TEXTURES: Record<string, string> = { const RETICLE_TEXTURES: Record<string, string> = {
weapon_sniper: "gui/hud_ret_sniper", weapon_sniper: "gui/hud_ret_sniper",
weapon_shocklance: "gui/hud_ret_shocklance", weapon_shocklance: "gui/hud_ret_shocklance",
weapon_targeting: "gui/hud_ret_targlaser", weapon_targeting: "gui/hud_ret_targlaser",
}; };
function normalizeWeaponName(shape: string | undefined): string { function normalizeWeaponName(shape: string | undefined): string {
if (!shape) return ""; if (!shape) return "";
return shape.replace(/\.dts$/i, "").toLowerCase(); return shape.replace(/\.dts$/i, "").toLowerCase();
} }
function Reticle() { function Reticle() {
const weaponShape = useEngineSelector((state) => { const weaponShape = useEngineSelector((state) => {
const snap = state.playback.streamSnapshot; const snap = state.playback.streamSnapshot;
@ -98,9 +88,7 @@ function Reticle() {
</div> </div>
); );
} }
// ── Weapon HUD (right side weapon list) ── // ── Weapon HUD (right side weapon list) ──
/** Maps $WeaponsHudData indices to simple icon textures (no baked background) /** Maps $WeaponsHudData indices to simple icon textures (no baked background)
* and labels. Mortar uses hud_new_ because no simple variant exists. */ * and labels. Mortar uses hud_new_ because no simple variant exists. */
const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = { const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
@ -124,7 +112,6 @@ const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
16: { icon: "gui/hud_shocklance", label: "Shocklance" }, 16: { icon: "gui/hud_shocklance", label: "Shocklance" },
17: { icon: "gui/hud_new_mortar", label: "Mortar" }, 17: { icon: "gui/hud_new_mortar", label: "Mortar" },
}; };
// Precompute URLs so we don't call textureToUrl on every render. // Precompute URLs so we don't call textureToUrl on every render.
const WEAPON_HUD_ICON_URLS = new Map( const WEAPON_HUD_ICON_URLS = new Map(
Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [ Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [
@ -132,12 +119,9 @@ const WEAPON_HUD_ICON_URLS = new Map(
textureToUrl(w.icon), textureToUrl(w.icon),
]), ]),
); );
/** Targeting laser HUD indices (standard + TR2 variants). */ /** Targeting laser HUD indices (standard + TR2 variants). */
const TARGETING_LASER_INDICES = new Set([9, 14, 15]); const TARGETING_LASER_INDICES = new Set([9, 14, 15]);
const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity"); const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity");
function WeaponSlotIcon({ function WeaponSlotIcon({
slot, slot,
isSelected, isSelected,
@ -171,7 +155,6 @@ function WeaponSlotIcon({
</div> </div>
); );
} }
function WeaponHUD() { function WeaponHUD() {
const weaponsHud = useEngineSelector( const weaponsHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.weaponsHud, (state) => state.playback.streamSnapshot?.weaponsHud,
@ -206,9 +189,7 @@ function WeaponHUD() {
</div> </div>
); );
} }
// ── Team Scores (bottom-left) ── // ── Team Scores (bottom-left) ──
/** Default team names from serverDefaults.cs. */ /** Default team names from serverDefaults.cs. */
const DEFAULT_TEAM_NAMES: Record<number, string> = { const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm", 1: "Storm",
@ -218,7 +199,6 @@ const DEFAULT_TEAM_NAMES: Record<number, string> = {
5: "Blood Eagle", 5: "Blood Eagle",
6: "Phoenix", 6: "Phoenix",
}; };
function TeamScores() { function TeamScores() {
const teamScores = useEngineSelector( const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores, (state) => state.playback.streamSnapshot?.teamScores,
@ -227,7 +207,6 @@ function TeamScores() {
(state) => state.playback.streamSnapshot?.playerSensorGroup, (state) => state.playback.streamSnapshot?.playerSensorGroup,
); );
if (!teamScores?.length) return null; if (!teamScores?.length) return null;
// Sort: friendly team first (if known), then by teamId. // Sort: friendly team first (if known), then by teamId.
const sorted = [...teamScores].sort((a, b) => { const sorted = [...teamScores].sort((a, b) => {
if (playerSensorGroup) { if (playerSensorGroup) {
@ -236,7 +215,6 @@ function TeamScores() {
} }
return a.teamId - b.teamId; return a.teamId - b.teamId;
}); });
return ( return (
<div className={styles.TeamScores}> <div className={styles.TeamScores}>
{sorted.map((team: TeamScore) => { {sorted.map((team: TeamScore) => {
@ -262,9 +240,7 @@ function TeamScores() {
</div> </div>
); );
} }
// ── Chat Window (top-left) ── // ── Chat Window (top-left) ──
/** Map a colorCode to a CSS module class name (c0c9 GuiChatHudProfile). */ /** Map a colorCode to a CSS module class name (c0c9 GuiChatHudProfile). */
const CHAT_COLOR_CLASSES: Record<number, string> = { const CHAT_COLOR_CLASSES: Record<number, string> = {
0: styles.ChatColor0, 0: styles.ChatColor0,
@ -278,11 +254,9 @@ const CHAT_COLOR_CLASSES: Record<number, string> = {
8: styles.ChatColor8, 8: styles.ChatColor8,
9: styles.ChatColor9, 9: styles.ChatColor9,
}; };
function segmentColorClass(colorCode: number): string { function segmentColorClass(colorCode: number): string {
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0]; return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
} }
function chatColorClass(msg: DemoChatMessage): string { function chatColorClass(msg: DemoChatMessage): string {
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) { if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
return CHAT_COLOR_CLASSES[msg.colorCode]; return CHAT_COLOR_CLASSES[msg.colorCode];
@ -292,7 +266,6 @@ function chatColorClass(msg: DemoChatMessage): string {
// byte color code, so the correct default for server messages is c0. // byte color code, so the correct default for server messages is c0.
return CHAT_COLOR_CLASSES[0]; return CHAT_COLOR_CLASSES[0];
} }
function ChatWindow() { function ChatWindow() {
const messages = useEngineSelector( const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages, (state) => state.playback.streamSnapshot?.chatMessages,
@ -340,9 +313,7 @@ function ChatWindow() {
</div> </div>
); );
} }
// ── Backpack + Inventory HUD (bottom-right) ── // ── Backpack + Inventory HUD (bottom-right) ──
/** Maps $BackpackHudData indices to icon textures. */ /** Maps $BackpackHudData indices to icon textures. */
const BACKPACK_ICONS: Record<number, string> = { const BACKPACK_ICONS: Record<number, string> = {
0: "gui/hud_new_packammo", 0: "gui/hud_new_packammo",
@ -366,7 +337,6 @@ const BACKPACK_ICONS: Record<number, string> = {
18: "gui/hud_satchel_unarmed", 18: "gui/hud_satchel_unarmed",
19: "gui/hud_new_packenergy", 19: "gui/hud_new_packenergy",
}; };
/** Pack indices that have an armed/activated icon variant. */ /** Pack indices that have an armed/activated icon variant. */
const BACKPACK_ARMED_ICONS: Record<number, string> = { const BACKPACK_ARMED_ICONS: Record<number, string> = {
1: "gui/hud_new_packcloak_armed", 1: "gui/hud_new_packcloak_armed",
@ -375,7 +345,6 @@ const BACKPACK_ARMED_ICONS: Record<number, string> = {
5: "gui/hud_new_packshield_armed", 5: "gui/hud_new_packshield_armed",
11: "gui/hud_new_packsensjam_armed", 11: "gui/hud_new_packsensjam_armed",
}; };
// Precompute URLs. // Precompute URLs.
const BACKPACK_ICON_URLS = new Map( const BACKPACK_ICON_URLS = new Map(
Object.entries(BACKPACK_ICONS).map(([idx, tex]) => [ Object.entries(BACKPACK_ICONS).map(([idx, tex]) => [
@ -389,7 +358,6 @@ const BACKPACK_ARMED_ICON_URLS = new Map(
textureToUrl(tex), textureToUrl(tex),
]), ]),
); );
/** Simple icons per inventory display slot (no baked-in background). */ /** Simple icons per inventory display slot (no baked-in background). */
const INVENTORY_SLOT_ICONS: Record<number, { icon: string; label: string }> = { const INVENTORY_SLOT_ICONS: Record<number, { icon: string; label: string }> = {
0: { icon: "gui/hud_handgren", label: "Grenade" }, 0: { icon: "gui/hud_handgren", label: "Grenade" },
@ -397,14 +365,12 @@ const INVENTORY_SLOT_ICONS: Record<number, { icon: string; label: string }> = {
2: { icon: "gui/hud_beacon", label: "Beacon" }, 2: { icon: "gui/hud_beacon", label: "Beacon" },
3: { icon: "gui/hud_medpack", label: "Repair Kit" }, 3: { icon: "gui/hud_medpack", label: "Repair Kit" },
}; };
const INVENTORY_ICON_URLS = new Map( const INVENTORY_ICON_URLS = new Map(
Object.entries(INVENTORY_SLOT_ICONS).map(([slot, info]) => [ Object.entries(INVENTORY_SLOT_ICONS).map(([slot, info]) => [
Number(slot), Number(slot),
textureToUrl(info.icon), textureToUrl(info.icon),
]), ]),
); );
function PackAndInventoryHUD() { function PackAndInventoryHUD() {
const backpackHud = useEngineSelector( const backpackHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.backpackHud, (state) => state.playback.streamSnapshot?.backpackHud,
@ -412,9 +378,7 @@ function PackAndInventoryHUD() {
const inventoryHud = useEngineSelector( const inventoryHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.inventoryHud, (state) => state.playback.streamSnapshot?.inventoryHud,
); );
const hasPack = backpackHud && backpackHud.packIndex >= 0; const hasPack = backpackHud && backpackHud.packIndex >= 0;
// Resolve pack icon. // Resolve pack icon.
let packIconUrl: string | undefined; let packIconUrl: string | undefined;
if (hasPack) { if (hasPack) {
@ -423,7 +387,6 @@ function PackAndInventoryHUD() {
: undefined; : undefined;
packIconUrl = armedUrl ?? BACKPACK_ICON_URLS.get(backpackHud.packIndex); packIconUrl = armedUrl ?? BACKPACK_ICON_URLS.get(backpackHud.packIndex);
} }
// Build count lookup from snapshot data. // Build count lookup from snapshot data.
const countBySlot = new Map<number, number>(); const countBySlot = new Map<number, number>();
if (inventoryHud) { if (inventoryHud) {
@ -431,14 +394,11 @@ function PackAndInventoryHUD() {
countBySlot.set(s.slot, s.count); countBySlot.set(s.slot, s.count);
} }
} }
// Always show all inventory slot types, defaulting to 0. // Always show all inventory slot types, defaulting to 0.
const allSlotIds = Object.keys(INVENTORY_SLOT_ICONS) const allSlotIds = Object.keys(INVENTORY_SLOT_ICONS)
.map(Number) .map(Number)
.sort((a, b) => a - b); .sort((a, b) => a - b);
if (!hasPack && !countBySlot.size) return null; if (!hasPack && !countBySlot.size) return null;
return ( return (
<div className={styles.PackInventoryHUD}> <div className={styles.PackInventoryHUD}>
{packIconUrl && ( {packIconUrl && (
@ -473,19 +433,15 @@ function PackAndInventoryHUD() {
</div> </div>
); );
} }
// ── Main HUD ── // ── Main HUD ──
export function PlayerHUD() { export function PlayerHUD() {
const recording = useDemoRecording(); const recording = useDemoRecording();
const streamSnapshot = useEngineSelector( const streamSnapshot = useEngineSelector(
(state) => state.playback.streamSnapshot, (state) => state.playback.streamSnapshot,
); );
if (!recording) return null; if (!recording) return null;
const status = streamSnapshot?.status; const status = streamSnapshot?.status;
if (!status) return null; if (!status) return null;
return ( return (
<div className={styles.PlayerHUD}> <div className={styles.PlayerHUD}>
<ChatWindow /> <ChatWindow />

View file

@ -151,6 +151,7 @@ export function getPosedNodeTransform(
scene: Group, scene: Group,
animations: AnimationClip[], animations: AnimationClip[],
nodeName: string, nodeName: string,
overrideClipNames?: string[],
): { position: Vector3; quaternion: Quaternion } | null { ): { position: Vector3; quaternion: Quaternion } | null {
const clone = scene.clone(true); const clone = scene.clone(true);
@ -158,6 +159,21 @@ export function getPosedNodeTransform(
if (rootClip) { if (rootClip) {
const mixer = new AnimationMixer(clone); const mixer = new AnimationMixer(clone);
mixer.clipAction(rootClip).play(); mixer.clipAction(rootClip).play();
// Play override clips (e.g. arm pose) which replace bone transforms
// on the bones they animate, at clip midpoint (neutral pose).
if (overrideClipNames) {
for (const name of overrideClipNames) {
const clip = animations.find(
(a) => a.name.toLowerCase() === name.toLowerCase(),
);
if (clip) {
const action = mixer.clipAction(clip);
action.time = clip.duration / 2;
action.setEffectiveTimeScale(0);
action.play();
}
}
}
mixer.setTime(0); mixer.setTime(0);
} }
@ -398,6 +414,8 @@ export function buildStreamDemoEntity(
ghostIndex: number | undefined, ghostIndex: number | undefined,
dataBlockId: number | undefined, dataBlockId: number | undefined,
shapeHint: string | undefined, shapeHint: string | undefined,
explosionDataBlockId?: number,
faceViewer?: boolean,
): DemoEntity { ): DemoEntity {
return { return {
id, id,
@ -411,6 +429,8 @@ export function buildStreamDemoEntity(
ghostIndex, ghostIndex,
dataBlockId, dataBlockId,
shapeHint, shapeHint,
explosionDataBlockId,
faceViewer,
keyframes: [ keyframes: [
{ {
time: 0, time: 0,

View file

@ -7,6 +7,7 @@ import {
import { Matrix4, Quaternion } from "three"; import { Matrix4, Quaternion } from "three";
import { getTerrainHeightAt } from "../terrainHeight"; import { getTerrainHeightAt } from "../terrainHeight";
import type { import type {
BackpackHudState,
ChatSegment, ChatSegment,
DemoChatMessage, DemoChatMessage,
DemoThreadState, DemoThreadState,
@ -96,6 +97,10 @@ interface MutableStreamEntity {
headPitch?: number; headPitch?: number;
/** Head yaw for blend animations (freelook), normalized [-1,1]. */ /** Head yaw for blend animations (freelook), normalized [-1,1]. */
headYaw?: number; headYaw?: number;
/** Target render flags bitmask from the Target Manager. */
targetRenderFlags?: number;
/** True when FlagImage is mounted in slot 3 (player is carrying a flag). */
carryingFlag?: boolean;
/** Item physics simulation state (dropped weapons/items). */ /** Item physics simulation state (dropped weapons/items). */
itemPhysics?: { itemPhysics?: {
velocity: [number, number, number]; velocity: [number, number, number];
@ -153,6 +158,8 @@ interface StreamState {
}; };
/** Team scores aggregated from the PLAYERLIST demoValues section. */ /** Team scores aggregated from the PLAYERLIST demoValues section. */
teamScores: TeamScore[]; teamScores: TeamScore[];
/** Live player roster keyed by clientId, updated by ServerMessage events. */
playerRoster: Map<number, { name: string; teamId: number }>;
} }
const TICK_DURATION_MS = 32; const TICK_DURATION_MS = 32;
@ -293,7 +300,10 @@ interface ParsedDemoValues {
activeSlot: number; activeSlot: number;
} | null; } | null;
teamScores: TeamScore[]; teamScores: TeamScore[];
/** Initial player roster from PLAYERLIST section, keyed by clientId. */
playerRoster: Map<number, { name: string; teamId: number }>;
chatMessages: string[]; chatMessages: string[];
gravity: number;
} }
/** /**
@ -309,7 +319,9 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
backpackHud: null, backpackHud: null,
inventoryHud: null, inventoryHud: null,
teamScores: [], teamScores: [],
playerRoster: new Map(),
chatMessages: [], chatMessages: [],
gravity: -20,
}; };
if (!demoValues.length) return result; if (!demoValues.length) return result;
@ -330,7 +342,12 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
const playerCountByTeam = new Map<number, number>(); const playerCountByTeam = new Map<number, number>();
for (let i = 0; i < playerCount; i++) { for (let i = 0; i < playerCount; i++) {
const fields = next().split("\t"); const fields = next().split("\t");
const name = fields[0] ?? "";
const clientId = parseInt(fields[2], 10);
const teamId = parseInt(fields[4], 10); const teamId = parseInt(fields[4], 10);
if (!isNaN(clientId) && !isNaN(teamId)) {
result.playerRoster.set(clientId, { name, teamId });
}
if (!isNaN(teamId) && teamId > 0) { if (!isNaN(teamId) && teamId > 0) {
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1); playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
} }
@ -458,7 +475,13 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
} }
} }
// GRAVITY: 1 value — skip // GRAVITY: 1 value (the server's getGravity() value).
if (idx < demoValues.length) {
const g = parseFloat(next());
if (Number.isFinite(g)) {
result.gravity = g;
}
}
return result; return result;
} }
@ -983,6 +1006,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
targetId: number; targetId: number;
name?: string; name?: string;
sensorGroup: number; sensorGroup: number;
targetData: number;
}>; }>;
sensorGroupColors: Array<{ sensorGroupColors: Array<{
group: number; group: number;
@ -1002,6 +1026,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
private readonly netStrings = new Map<number, string>(); private readonly netStrings = new Map<number, string>();
private readonly targetNames = new Map<number, string>(); private readonly targetNames = new Map<number, string>();
private readonly targetTeams = new Map<number, number>(); private readonly targetTeams = new Map<number, number>();
private readonly targetRenderFlags = new Map<number, number>();
/** IFF color map: for the viewer's sensorGroup, map target sensorGroup → RGB. */ /** IFF color map: for the viewer's sensorGroup, map target sensorGroup → RGB. */
private readonly sensorGroupColors = new Map< private readonly sensorGroupColors = new Map<
number, number,
@ -1009,6 +1034,31 @@ class StreamingPlayback implements DemoStreamingPlayback {
>(); >();
private state: StreamState; private state: StreamState;
// Generation counters for derived-array caching in buildSnapshot().
private _teamScoresGen = 0;
private _rosterGen = 0;
private _weaponsHudGen = 0;
private _inventoryHudGen = 0;
// Cached snapshot returned when no ticks advance between stepToTime() calls.
private _cachedSnapshot: DemoStreamSnapshot | null = null;
private _cachedSnapshotTick = -1;
// Cached derived arrays from the last buildSnapshot() call.
private _snap: {
teamScoresGen: number;
rosterGen: number;
teamScores: TeamScore[];
weaponsHudGen: number;
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
inventoryHudGen: number;
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
backpackPackIndex: number;
backpackActive: boolean;
backpackText: string;
backpackHud: BackpackHudState | null;
} | null = null;
constructor(parser: DemoParser) { constructor(parser: DemoParser) {
this.parser = parser; this.parser = parser;
this.registry = parser.getRegistry(); this.registry = parser.getRegistry();
@ -1055,6 +1105,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
backpackHud: { packIndex: -1, active: false, text: "" }, backpackHud: { packIndex: -1, active: false, text: "" },
inventoryHud: { slots: new Map(), activeSlot: -1 }, inventoryHud: { slots: new Map(), activeSlot: -1 },
teamScores: [], teamScores: [],
playerRoster: new Map(),
}; };
this.reset(); this.reset();
@ -1062,10 +1113,14 @@ class StreamingPlayback implements DemoStreamingPlayback {
reset(): void { reset(): void {
this.parser.reset(); this.parser.reset();
this._cachedSnapshot = null;
this._cachedSnapshotTick = -1;
this._snap = null;
this.netStrings.clear(); this.netStrings.clear();
this.targetNames.clear(); this.targetNames.clear();
this.targetTeams.clear(); this.targetTeams.clear();
this.targetRenderFlags.clear();
this.sensorGroupColors.clear(); this.sensorGroupColors.clear();
this.state.entitiesById.clear(); this.state.entitiesById.clear();
this.state.entityIdByGhostIndex.clear(); this.state.entityIdByGhostIndex.clear();
@ -1081,6 +1136,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
); );
} }
this.targetTeams.set(entry.targetId, entry.sensorGroup); this.targetTeams.set(entry.targetId, entry.sensorGroup);
this.targetRenderFlags.set(entry.targetId, entry.targetData);
} }
// Seed IFF color table from the initial block. // Seed IFF color table from the initial block.
for (const c of this.initialBlock.sensorGroupColors) { for (const c of this.initialBlock.sensorGroupColors) {
@ -1099,6 +1155,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.backpackHud = { packIndex: -1, active: false, text: "" }; this.state.backpackHud = { packIndex: -1, active: false, text: "" };
this.state.inventoryHud = { slots: new Map(), activeSlot: -1 }; this.state.inventoryHud = { slots: new Map(), activeSlot: -1 };
this.state.teamScores = []; this.state.teamScores = [];
this.state.playerRoster = new Map();
this.state.moveTicks = 0; this.state.moveTicks = 0;
this.state.absoluteYaw = 0; this.state.absoluteYaw = 0;
this.state.absolutePitch = 0; this.state.absolutePitch = 0;
@ -1227,6 +1284,9 @@ class StreamingPlayback implements DemoStreamingPlayback {
evt.parsedData.funcName as string, evt.parsedData.funcName as string,
); );
const args = evt.parsedData.args as string[]; const args = evt.parsedData.args as string[];
if (funcName === "ServerMessage") {
this.handleServerMessage(args);
}
this.handleHudRemoteCommand(funcName, args); this.handleHudRemoteCommand(funcName, args);
} }
} }
@ -1248,6 +1308,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot; this.state.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot;
} }
this.state.teamScores = parsed.teamScores; this.state.teamScores = parsed.teamScores;
this.state.playerRoster = new Map(parsed.playerRoster);
// Seed chat messages at time 0 so they appear at start and fade naturally. // Seed chat messages at time 0 so they appear at start and fade naturally.
// Raw lines from HudMessageVector contain Torque control chars: collapsed // Raw lines from HudMessageVector contain Torque control chars: collapsed
// color bytes (0x020x0e via collapseRemap), tagged string markup // color bytes (0x020x0e via collapseRemap), tagged string markup
@ -1293,17 +1354,37 @@ class StreamingPlayback implements DemoStreamingPlayback {
} }
getSnapshot(): DemoStreamSnapshot { getSnapshot(): DemoStreamSnapshot {
return this.buildSnapshot(); if (this._cachedSnapshot && this._cachedSnapshotTick === this.state.moveTicks) {
return this._cachedSnapshot;
}
const snapshot = this.buildSnapshot();
this._cachedSnapshot = snapshot;
this._cachedSnapshotTick = this.state.moveTicks;
return snapshot;
} }
getEffectShapes(): string[] { getEffectShapes(): string[] {
const shapes = new Set<string>(); const shapes = new Set<string>();
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
const shape = expBlock.dtsFileName as string | undefined;
if (shape) shapes.add(shape);
// Sub-explosions also have DTS shapes (e.g. mortar sub-explosions).
const subExplosions = expBlock.subExplosions as (number | null)[] | undefined;
if (Array.isArray(subExplosions)) {
for (const subId of subExplosions) {
if (subId == null) continue;
const subBlock = this.getDataBlockData(subId);
if (subBlock?.dtsFileName) {
shapes.add(subBlock.dtsFileName as string);
}
}
}
};
for (const [, block] of this.initialBlock.dataBlocks) { for (const [, block] of this.initialBlock.dataBlocks) {
const explosionId = block.data?.explosion as number | undefined; const explosionId = block.data?.explosion as number | undefined;
if (explosionId == null) continue; if (explosionId == null) continue;
const expBlock = this.getDataBlockData(explosionId); const expBlock = this.getDataBlockData(explosionId);
const shape = expBlock?.dtsFileName as string | undefined; if (expBlock) collectShapesFromExplosion(expBlock);
if (shape) shapes.add(shape);
} }
return [...shapes]; return [...shapes];
} }
@ -1317,10 +1398,13 @@ class StreamingPlayback implements DemoStreamingPlayback {
: 0; : 0;
const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS); const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS);
let didReset = false;
if (targetTicks < this.state.moveTicks) { if (targetTicks < this.state.moveTicks) {
this.reset(); this.reset();
didReset = true;
} }
const wasExhausted = this.state.exhausted;
let movesProcessed = 0; let movesProcessed = 0;
while ( while (
!this.state.exhausted && !this.state.exhausted &&
@ -1333,7 +1417,20 @@ class StreamingPlayback implements DemoStreamingPlayback {
movesProcessed += 1; movesProcessed += 1;
} }
return this.buildSnapshot(); if (
movesProcessed === 0 &&
!didReset &&
wasExhausted === this.state.exhausted &&
this._cachedSnapshot &&
this._cachedSnapshotTick === this.state.moveTicks
) {
return this._cachedSnapshot;
}
const snapshot = this.buildSnapshot();
this._cachedSnapshot = snapshot;
this._cachedSnapshotTick = this.state.moveTicks;
return snapshot;
} }
private stepOneMoveTick(): boolean { private stepOneMoveTick(): boolean {
@ -1468,6 +1565,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
if (targetId != null && sensorGroup != null) { if (targetId != null && sensorGroup != null) {
this.targetTeams.set(targetId, sensorGroup); this.targetTeams.set(targetId, sensorGroup);
} }
const renderFlags = evt.parsedData.renderFlags as number | undefined;
if (targetId != null && renderFlags != null) {
this.targetRenderFlags.set(targetId, renderFlags);
// Propagate to any entity bound to this target so render flags
// take effect immediately (e.g. clearing bit 0x2 when a player
// drops a flag) rather than waiting for the next ghost update.
for (const entity of this.state.entitiesById.values()) {
if (entity.targetId === targetId) {
entity.targetRenderFlags = renderFlags;
}
}
}
} else if (eventName === "SetSensorGroupEvent" && evt.parsedData) { } else if (eventName === "SetSensorGroupEvent" && evt.parsedData) {
const sg = evt.parsedData.sensorGroup as number | undefined; const sg = evt.parsedData.sensorGroup as number | undefined;
if (sg != null) this.state.playerSensorGroup = sg; if (sg != null) this.state.playerSensorGroup = sg;
@ -1633,6 +1742,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
}); });
} }
} else if (funcName === "ServerMessage" && args.length >= 2) { } else if (funcName === "ServerMessage" && args.length >= 2) {
this.handleServerMessage(args);
const rawTemplate = this.resolveNetString(args[1]); const rawTemplate = this.resolveNetString(args[1]);
const serverColorCode = detectColorCode(rawTemplate); const serverColorCode = detectColorCode(rawTemplate);
const rawText = this.formatRemoteArgs(args[1], args.slice(2)); const rawText = this.formatRemoteArgs(args[1], args.slice(2));
@ -1718,19 +1828,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
// When a projectile entity is being removed (ghost delete, ghost index // When a projectile entity is being removed (ghost delete, ghost index
// reuse, or same-class index reuse), spawn an explosion at its last known // reuse, or same-class index reuse), spawn an explosion at its last known
// position if it hasn't already exploded. The Torque engine's KillGhost // position if it hasn't already exploded. Explosion positions usually
// mechanism silently drops pending ExplosionMask data when a ghost goes // arrive via ExplosionMask in ghost updates (the server has a 13-tick /
// out of scope, so explosion positions almost never arrive in the demo // 416ms DeleteWaitTicks window before KillGhost fires), but this fallback
// stream. The original client compensated with client-side raycast // catches cases where the explicit data was missed — e.g. network
// collision detection in processTick(); we approximate by triggering the // congestion or the projectile going out of scope before the update.
// explosion when the ghost disappears.
if (prevEntityId) { if (prevEntityId) {
const prevEntity = this.state.entitiesById.get(prevEntityId); const prevEntity = this.state.entitiesById.get(prevEntityId);
if ( if (
prevEntity && prevEntity &&
prevEntity.type === "Projectile" && prevEntity.type === "Projectile" &&
!prevEntity.hasExploded && !prevEntity.hasExploded &&
prevEntity.explosionShape && prevEntity.explosionDataBlockId != null &&
prevEntity.position && prevEntity.position &&
// Ghost is being deleted or its index is being reassigned to a new // Ghost is being deleted or its index is being reassigned to a new
// ghost (either a different class or a fresh create of the same class). // ghost (either a different class or a fresh create of the same class).
@ -1784,6 +1893,27 @@ class StreamingPlayback implements DemoStreamingPlayback {
existingEntity.dataBlockId = undefined; existingEntity.dataBlockId = undefined;
existingEntity.shapeHint = undefined; existingEntity.shapeHint = undefined;
existingEntity.visual = undefined; existingEntity.visual = undefined;
existingEntity.targetId = undefined;
existingEntity.targetRenderFlags = undefined;
existingEntity.carryingFlag = undefined;
existingEntity.sensorGroup = undefined;
existingEntity.playerName = undefined;
existingEntity.weaponShape = undefined;
existingEntity.weaponImageState = undefined;
existingEntity.weaponImageStates = undefined;
existingEntity.weaponImageStatesDbId = undefined;
existingEntity.itemPhysics = undefined;
existingEntity.threads = undefined;
existingEntity.headPitch = undefined;
existingEntity.headYaw = undefined;
existingEntity.health = undefined;
existingEntity.energy = undefined;
existingEntity.maxEnergy = undefined;
existingEntity.damageState = undefined;
existingEntity.actionAnim = undefined;
existingEntity.actionAtEnd = undefined;
existingEntity.explosionDataBlockId = undefined;
existingEntity.maintainEmitterId = undefined;
entity = existingEntity; entity = existingEntity;
} else if (existingEntity) { } else if (existingEntity) {
entity = existingEntity; entity = existingEntity;
@ -1866,12 +1996,15 @@ class StreamingPlayback implements DemoStreamingPlayback {
} }
| undefined { | undefined {
const projBlock = this.getDataBlockData(projDataBlockId); const projBlock = this.getDataBlockData(projDataBlockId);
const explosionId = projBlock?.explosion as number | undefined; if (!projBlock) return undefined;
const explosionId = projBlock.explosion as number | undefined;
if (explosionId == null) return undefined; if (explosionId == null) return undefined;
const expBlock = this.getDataBlockData(explosionId); const expBlock = this.getDataBlockData(explosionId);
if (!expBlock) return undefined; if (!expBlock) return undefined;
const shape = expBlock.dtsFileName as string | undefined; // dtsFileName may be empty for particle-only explosions (e.g. grenades,
if (!shape) return undefined; // energy projectiles). Still return info so we can spawn the explosion
// entity for position tracking and particle effects.
const shape = (expBlock.dtsFileName as string | undefined) || undefined;
// The parser's lifetimeMS field is actually in ticks (32ms each), not ms. // The parser's lifetimeMS field is actually in ticks (32ms each), not ms.
const lifetimeTicks = (expBlock.lifetimeMS as number | undefined) ?? 31; const lifetimeTicks = (expBlock.lifetimeMS as number | undefined) ?? 31;
return { return {
@ -1915,14 +2048,15 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.projectilePhysics = "linear"; entity.projectilePhysics = "linear";
} else if (ballisticProjectileClassNames.has(entity.className)) { } else if (ballisticProjectileClassNames.has(entity.className)) {
entity.projectilePhysics = "ballistic"; entity.projectilePhysics = "ballistic";
entity.gravityMod = getNumberField(blockData, ["gravityMod"]) ?? 1.0; entity.gravityMod =
getNumberField(blockData, ["gravityMod"]) ?? 1.0;
} else if (seekerProjectileClassNames.has(entity.className)) { } else if (seekerProjectileClassNames.has(entity.className)) {
entity.projectilePhysics = "seeker"; entity.projectilePhysics = "seeker";
} }
} }
// Resolve explosion shape info for projectiles (once per entity). // Resolve explosion shape info for projectiles (once per entity).
if (entity.type === "Projectile" && !entity.explosionShape) { if (entity.type === "Projectile" && entity.explosionDataBlockId == null) {
const info = this.resolveExplosionInfo(dataBlockId); const info = this.resolveExplosionInfo(dataBlockId);
if (info) { if (info) {
entity.explosionShape = info.shape; entity.explosionShape = info.shape;
@ -2000,6 +2134,24 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.weaponImageState = undefined; entity.weaponImageState = undefined;
entity.weaponImageStates = undefined; entity.weaponImageStates = undefined;
} }
// Track FlagImage in slot 3 ($FlagSlot). The server mounts FlagImage
// when a player picks up a flag and unmounts it on drop. buildSnapshot
// uses carryingFlag to gate the flag icon on Players, which prevents
// dead corpses (sharing the same targetId) from showing duplicate icons.
const flagImage = images.find((img) => img.index === 3);
if (flagImage) {
const hasFlag = !!flagImage.dataBlockId && flagImage.dataBlockId > 0;
entity.carryingFlag = hasFlag;
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = hasFlag ? prev | 0x2 : prev & ~0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
} }
} }
@ -2093,8 +2245,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
// Item physics: simulate dropped items falling under gravity and bouncing. // Item physics: simulate dropped items falling under gravity and bouncing.
if (entity.type === "Item") { if (entity.type === "Item") {
const atRest = data.atRest as boolean | undefined; const atRest = data.atRest as boolean | undefined;
if (atRest === true) { const warp = data.warp as boolean | undefined;
// Server says item is at rest — stop simulating. if (atRest === true || warp === false) {
// At rest, or position authoritatively set (e.g. flag returned to
// base via setTransform → NoWarpMask) — stop simulating.
entity.itemPhysics = undefined; entity.itemPhysics = undefined;
} else if (atRest === false && isVec3Like(data.velocity)) { } else if (atRest === false && isVec3Like(data.velocity)) {
// Item is moving — initialize or update physics simulation. // Item is moving — initialize or update physics simulation.
@ -2115,7 +2269,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
} }
} }
// Compute simulatedVelocity for projectile physics. // Compute simulatedVelocity for projectile physics. Mirrors the engine's
// unpackUpdate: velocity is only set on InitialUpdateMask or BounceMask,
// so we only (re)initialize when this update actually transmits velocity
// or direction data. Between updates, advanceProjectiles() accumulates
// gravity on the existing simulatedVelocity.
if (entity.projectilePhysics) { if (entity.projectilePhysics) {
if (entity.projectilePhysics === "linear") { if (entity.projectilePhysics === "linear") {
// Linear projectiles transmit direction + dryVelocity from datablock, // Linear projectiles transmit direction + dryVelocity from datablock,
@ -2147,14 +2305,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
vz += excessDir.z * excessVel; vz += excessDir.z * excessVel;
} }
entity.simulatedVelocity = [vx, vy, vz]; entity.simulatedVelocity = [vx, vy, vz];
} else if (entity.velocity) { } else if (isVec3Like(data.velocity)) {
// Ballistic and seeker: use the transmitted velocity directly. // Ballistic/seeker: set velocity only when this ghost update transmits
// it (initial create or BounceMask). The engine's unpackUpdate only
// writes mCurrVelocity on these two mask bits.
entity.simulatedVelocity = [ entity.simulatedVelocity = [
entity.velocity[0], data.velocity.x,
entity.velocity[1], data.velocity.y,
entity.velocity[2], data.velocity.z,
]; ];
} }
}
if (entity.projectilePhysics) {
// Fast-forward by currTick: the initial position is the firing point // Fast-forward by currTick: the initial position is the firing point
// and currTick tells us how many ticks have already elapsed. // and currTick tells us how many ticks have already elapsed.
@ -2172,10 +2334,12 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.position[2] += v[2] * dt; entity.position[2] += v[2] * dt;
// For ballistic projectiles, also apply gravity during fast-forward. // For ballistic projectiles, also apply gravity during fast-forward.
if (entity.projectilePhysics === "ballistic") { if (entity.projectilePhysics === "ballistic") {
const g = 9.81 * (entity.gravityMod ?? 1); // GrenadeProjectile::computeNewState uses -9.81 * gravityMod
// (globalGravity * 0.4905 * gravityMod, where 0.4905 = 9.81/20).
const g = -9.81 * (entity.gravityMod ?? 1);
// v.z changes linearly, position.z changes quadratically. // v.z changes linearly, position.z changes quadratically.
entity.position[2] -= 0.5 * g * dt * dt; entity.position[2] += 0.5 * g * dt * dt;
v[2] -= g * dt; v[2] += g * dt;
} }
} }
} }
@ -2193,7 +2357,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.type === "Projectile" && entity.type === "Projectile" &&
!entity.hasExploded && !entity.hasExploded &&
explodePos && explodePos &&
entity.explosionShape entity.explosionDataBlockId != null
) { ) {
this.spawnExplosion(entity, [explodePos.x, explodePos.y, explodePos.z]); this.spawnExplosion(entity, [explodePos.x, explodePos.y, explodePos.z]);
} }
@ -2252,6 +2416,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.playerSensorGroup = team; this.state.playerSensorGroup = team;
} }
} }
const renderFlags = this.targetRenderFlags.get(data.targetId);
if (renderFlags != null) {
entity.targetRenderFlags = renderFlags;
}
} }
// SoundMask: ghost-level playAudio() calls (e.g. station activation). // SoundMask: ghost-level playAudio() calls (e.g. station activation).
@ -2285,8 +2453,8 @@ class StreamingPlayback implements DemoStreamingPlayback {
const p = entity.position; const p = entity.position;
if (entity.projectilePhysics === "ballistic") { if (entity.projectilePhysics === "ballistic") {
const g = 9.81 * (entity.gravityMod ?? 1); // GrenadeProjectile::computeNewState: -9.81 * gravityMod per tick.
v[2] -= g * dt; v[2] += -9.81 * (entity.gravityMod ?? 1) * dt;
} }
p[0] += v[0] * dt; p[0] += v[0] * dt;
@ -2346,8 +2514,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
position: [number, number, number], position: [number, number, number],
): void { ): void {
entity.hasExploded = true; entity.hasExploded = true;
const fxId = `fx_${this.state.nextExplosionId++}`;
const lifetimeTicks = entity.explosionLifetimeTicks ?? 31; const lifetimeTicks = entity.explosionLifetimeTicks ?? 31;
// Spawn the main explosion entity.
const fxId = `fx_${this.state.nextExplosionId++}`;
const fxEntity: MutableStreamEntity = { const fxEntity: MutableStreamEntity = {
id: fxId, id: fxId,
ghostIndex: -1, ghostIndex: -1,
@ -2363,6 +2533,56 @@ class StreamingPlayback implements DemoStreamingPlayback {
expiryTick: this.state.moveTicks + lifetimeTicks, expiryTick: this.state.moveTicks + lifetimeTicks,
}; };
this.state.entitiesById.set(fxId, fxEntity); this.state.entitiesById.set(fxId, fxEntity);
// Spawn sub-explosion entities (e.g. MortarSubExplosion1/2/3 carry the
// actual DTS shapes while the main MortarExplosion has none).
if (entity.explosionDataBlockId != null) {
const expBlock = this.getDataBlockData(entity.explosionDataBlockId);
const subExplosions = expBlock?.subExplosions as
| (number | null)[]
| undefined;
if (Array.isArray(subExplosions)) {
for (const subId of subExplosions) {
if (subId == null) continue;
const subBlock = this.getDataBlockData(subId);
if (!subBlock) continue;
const subShape =
(subBlock.dtsFileName as string | undefined) || undefined;
if (!subShape) continue;
const subLifetimeTicks =
(subBlock.lifetimeMS as number | undefined) ?? 31;
const offset = (subBlock.offset as number | undefined) ?? 0;
// Randomize position offset in XY plane (Torque convention).
const angle = Math.random() * Math.PI * 2;
const subPos: [number, number, number] = [
position[0] + Math.cos(angle) * offset,
position[1] + Math.sin(angle) * offset,
position[2],
];
const subFxId = `fx_${this.state.nextExplosionId++}`;
const subFxEntity: MutableStreamEntity = {
id: subFxId,
ghostIndex: -1,
className: "Explosion",
spawnTick: this.state.moveTicks,
type: "Explosion",
dataBlock: subShape,
explosionDataBlockId: subId,
position: subPos,
rotation: [0, 0, 0, 1],
isExplosion: true,
faceViewer:
subBlock.faceViewer !== false && subBlock.faceViewer !== 0,
expiryTick: this.state.moveTicks + subLifetimeTicks,
};
this.state.entitiesById.set(subFxId, subFxEntity);
}
}
}
// Stop the projectile — the explosion takes over visually. // Stop the projectile — the explosion takes over visually.
entity.position = undefined; entity.position = undefined;
entity.simulatedVelocity = undefined; entity.simulatedVelocity = undefined;
@ -2517,6 +2737,81 @@ class StreamingPlayback implements DemoStreamingPlayback {
} }
} }
/** Process ServerMessage events that update team scores and player roster. */
private handleServerMessage(args: string[]): void {
if (args.length < 2) return;
const msgType = this.resolveNetString(args[0]);
if (msgType === "MsgTeamScoreIs" && args.length >= 4) {
// args: [msgType, "", teamId, newScore]
const teamId = parseInt(this.resolveNetString(args[2]), 10);
const newScore = parseInt(this.resolveNetString(args[3]), 10);
if (!isNaN(teamId) && !isNaN(newScore)) {
const entry = this.state.teamScores.find((t) => t.teamId === teamId);
if (entry) {
entry.score = newScore;
this._teamScoresGen++;
}
}
} else if (msgType === "MsgCTFAddTeam" && args.length >= 6) {
// args: [msgType, "", teamIdx, teamName, flagStatus, score]
const teamIdx = parseInt(this.resolveNetString(args[2]), 10);
const teamName = stripTaggedStringMarkup(this.resolveNetString(args[3]));
const score = parseInt(this.resolveNetString(args[5]), 10);
if (!isNaN(teamIdx)) {
const teamId = teamIdx + 1;
const existing = this.state.teamScores.find(
(t) => t.teamId === teamId,
);
if (existing) {
existing.name = teamName;
existing.score = isNaN(score) ? existing.score : score;
this._teamScoresGen++;
} else {
this.state.teamScores.push({
teamId,
name: teamName,
score: isNaN(score) ? 0 : score,
playerCount: 0,
});
this._teamScoresGen++;
}
}
} else if (msgType === "MsgClientJoin" && args.length >= 4) {
// args: [msgType, "", clientId, name, ...]
const clientId = parseInt(this.resolveNetString(args[2]), 10);
const name = stripTaggedStringMarkup(this.resolveNetString(args[3]));
if (!isNaN(clientId)) {
const existing = this.state.playerRoster.get(clientId);
this.state.playerRoster.set(clientId, {
name,
teamId: existing?.teamId ?? 0,
});
this._rosterGen++;
}
} else if (msgType === "MsgClientDrop" && args.length >= 3) {
// args: [msgType, "", clientId, ...]
const clientId = parseInt(this.resolveNetString(args[2]), 10);
if (!isNaN(clientId)) {
this.state.playerRoster.delete(clientId);
this._rosterGen++;
}
} else if (msgType === "MsgClientJoinTeam" && args.length >= 4) {
// args: [msgType, "", clientId, teamId, ...]
const clientId = parseInt(this.resolveNetString(args[2]), 10);
const teamId = parseInt(this.resolveNetString(args[3]), 10);
if (!isNaN(clientId) && !isNaN(teamId)) {
const existing = this.state.playerRoster.get(clientId);
if (existing) {
existing.teamId = teamId;
} else {
this.state.playerRoster.set(clientId, { name: "", teamId });
}
this._rosterGen++;
}
}
}
private handleHudRemoteCommand(funcName: string, args: string[]): void { private handleHudRemoteCommand(funcName: string, args: string[]): void {
// ── Weapons HUD ── // ── Weapons HUD ──
if (funcName === "setWeaponsHudItem" && args.length >= 3) { if (funcName === "setWeaponsHudItem" && args.length >= 3) {
@ -2529,6 +2824,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
} else { } else {
this.state.weaponsHud.slots.delete(slot); this.state.weaponsHud.slots.delete(slot);
} }
this._weaponsHudGen++;
} }
} else if (funcName === "setWeaponsHudAmmo" && args.length >= 2) { } else if (funcName === "setWeaponsHudAmmo" && args.length >= 2) {
const slot = parseInt(args[0], 10); const slot = parseInt(args[0], 10);
@ -2537,6 +2833,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
// Treat ammo updates as implicit inventory presence — the // Treat ammo updates as implicit inventory presence — the
// initial setWeaponsHudItem may have been sent before recording. // initial setWeaponsHudItem may have been sent before recording.
this.state.weaponsHud.slots.set(slot, isNaN(ammo) ? -1 : ammo); this.state.weaponsHud.slots.set(slot, isNaN(ammo) ? -1 : ammo);
this._weaponsHudGen++;
} }
} else if (funcName === "setWeaponsHudActive" && args.length >= 1) { } else if (funcName === "setWeaponsHudActive" && args.length >= 1) {
const slot = parseInt(args[0], 10); const slot = parseInt(args[0], 10);
@ -2547,9 +2844,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.weaponsHud.slots.set(slot, -1); this.state.weaponsHud.slots.set(slot, -1);
} }
} }
this._weaponsHudGen++;
} else if (funcName === "setWeaponsHudClearAll") { } else if (funcName === "setWeaponsHudClearAll") {
this.state.weaponsHud.slots.clear(); this.state.weaponsHud.slots.clear();
this.state.weaponsHud.activeIndex = -1; this.state.weaponsHud.activeIndex = -1;
this._weaponsHudGen++;
// ── Backpack HUD ── // ── Backpack HUD ──
} else if (funcName === "setBackpackHudItem" && args.length >= 2) { } else if (funcName === "setBackpackHudItem" && args.length >= 2) {
@ -2594,16 +2893,19 @@ class StreamingPlayback implements DemoStreamingPlayback {
} else { } else {
this.state.inventoryHud.slots.delete(slot); this.state.inventoryHud.slots.delete(slot);
} }
this._inventoryHudGen++;
} }
} else if (funcName === "setInventoryHudAmount" && args.length >= 2) { } else if (funcName === "setInventoryHudAmount" && args.length >= 2) {
const slot = parseInt(args[0], 10); const slot = parseInt(args[0], 10);
const amount = parseInt(args[1], 10); const amount = parseInt(args[1], 10);
if (!isNaN(slot) && !isNaN(amount)) { if (!isNaN(slot) && !isNaN(amount)) {
this.state.inventoryHud.slots.set(slot, amount); this.state.inventoryHud.slots.set(slot, amount);
this._inventoryHudGen++;
} }
} else if (funcName === "setInventoryHudClearAll") { } else if (funcName === "setInventoryHudClearAll") {
this.state.inventoryHud.slots.clear(); this.state.inventoryHud.slots.clear();
this.state.inventoryHud.activeSlot = -1; this.state.inventoryHud.activeSlot = -1;
this._inventoryHudGen++;
} }
} }
@ -2613,6 +2915,20 @@ class StreamingPlayback implements DemoStreamingPlayback {
if (!shouldRenderGhostEntity(entity)) { if (!shouldRenderGhostEntity(entity)) {
continue; continue;
} }
// Read the latest targetRenderFlags from the map (source of truth
// from TargetInfoEvents and FlagImage slot tracking) rather than the
// entity cache, which may be stale.
let renderFlags =
entity.targetId != null && entity.targetId >= 0
? (this.targetRenderFlags.get(entity.targetId) ??
entity.targetRenderFlags)
: entity.targetRenderFlags;
// For Players, only show the flag icon if this specific entity has
// FlagImage mounted in slot 3. Dead corpses share the same targetId as
// the alive player but don't have FlagImage, so this prevents duplicates.
if (entity.type === "Player" && !entity.carryingFlag) {
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
}
entities.push({ entities.push({
id: entity.id, id: entity.id,
type: entity.type, type: entity.type,
@ -2625,8 +2941,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
dataBlock: entity.dataBlock, dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape, weaponShape: entity.weaponShape,
playerName: entity.playerName, playerName: entity.playerName,
targetRenderFlags: renderFlags,
iffColor: iffColor:
entity.type === "Player" && entity.sensorGroup != null (entity.type === "Player" ||
((renderFlags ?? 0) & 0x2) !== 0) &&
entity.sensorGroup != null
? this.resolveIffColor(entity.sensorGroup) ? this.resolveIffColor(entity.sensorGroup)
: undefined, : undefined,
// Only clone position for entities whose position is mutated in-place // Only clone position for entities whose position is mutated in-place
@ -2658,6 +2977,80 @@ class StreamingPlayback implements DemoStreamingPlayback {
} }
const timeSec = this.state.moveTicks * (TICK_DURATION_MS / 1000); const timeSec = this.state.moveTicks * (TICK_DURATION_MS / 1000);
const prev = this._snap;
const chatMessages = this.state.chatMessages.filter(
(m) => m.timeSec > timeSec - 15,
);
const audioEvents = this.state.pendingAudioEvents.filter(
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
);
const weaponsHud =
prev && prev.weaponsHudGen === this._weaponsHudGen
? prev.weaponsHud
: {
slots: Array.from(this.state.weaponsHud.slots.entries()).map(
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
),
activeIndex: this.state.weaponsHud.activeIndex,
};
const inventoryHud =
prev && prev.inventoryHudGen === this._inventoryHudGen
? prev.inventoryHud
: {
slots: Array.from(this.state.inventoryHud.slots.entries()).map(
([slot, count]): InventoryHudSlot => ({ slot, count }),
),
activeSlot: this.state.inventoryHud.activeSlot,
};
const backpackHud =
prev &&
prev.backpackPackIndex === this.state.backpackHud.packIndex &&
prev.backpackActive === this.state.backpackHud.active &&
prev.backpackText === this.state.backpackHud.text
? prev.backpackHud
: this.state.backpackHud.packIndex >= 0
? { ...this.state.backpackHud }
: null;
let teamScores: TeamScore[];
if (
prev &&
prev.teamScoresGen === this._teamScoresGen &&
prev.rosterGen === this._rosterGen
) {
teamScores = prev.teamScores;
} else {
teamScores = this.state.teamScores.map((ts) => ({ ...ts }));
const teamCounts = new Map<number, number>();
for (const { teamId } of this.state.playerRoster.values()) {
if (teamId > 0) {
teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1);
}
}
for (const ts of teamScores) {
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
}
}
this._snap = {
teamScoresGen: this._teamScoresGen,
rosterGen: this._rosterGen,
teamScores,
weaponsHudGen: this._weaponsHudGen,
weaponsHud,
inventoryHudGen: this._inventoryHudGen,
inventoryHud,
backpackPackIndex: this.state.backpackHud.packIndex,
backpackActive: this.state.backpackHud.active,
backpackText: this.state.backpackHud.text,
backpackHud,
};
return { return {
timeSec, timeSec,
exhausted: this.state.exhausted, exhausted: this.state.exhausted,
@ -2666,29 +3059,12 @@ class StreamingPlayback implements DemoStreamingPlayback {
controlPlayerGhostId: this.state.controlPlayerGhostId, controlPlayerGhostId: this.state.controlPlayerGhostId,
playerSensorGroup: this.state.playerSensorGroup, playerSensorGroup: this.state.playerSensorGroup,
status: this.state.lastStatus, status: this.state.lastStatus,
chatMessages: this.state.chatMessages.filter( chatMessages,
(m) => m.timeSec > timeSec - 15, audioEvents,
), weaponsHud,
audioEvents: this.state.pendingAudioEvents.filter( backpackHud,
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec, inventoryHud,
), teamScores,
weaponsHud: {
slots: Array.from(this.state.weaponsHud.slots.entries()).map(
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
),
activeIndex: this.state.weaponsHud.activeIndex,
},
backpackHud:
this.state.backpackHud.packIndex >= 0
? { ...this.state.backpackHud }
: null,
inventoryHud: {
slots: Array.from(this.state.inventoryHud.slots.entries()).map(
([slot, count]): InventoryHudSlot => ({ slot, count }),
),
activeSlot: this.state.inventoryHud.activeSlot,
},
teamScores: this.state.teamScores,
}; };
} }
@ -2787,6 +3163,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
); );
} }
} }
// Torque drops trailing empty string args from commandToClient, so the
// template may reference %N tokens beyond the supplied args (e.g. a
// plural "s" that evaluates to ""). Replace any remaining placeholders.
resolved = resolved.replace(/%\d+/g, "");
return stripTaggedStringMarkup(resolved); return stripTaggedStringMarkup(resolved);
} }
} }

View file

@ -117,6 +117,8 @@ export interface DemoEntity {
playerName?: string; playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */ /** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number }; iffColor?: { r: number; g: number; b: number };
/** Target render flags bitmask from the Target Manager. */
targetRenderFlags?: number;
/** Weapon image condition flags from ghost ImageMask data. */ /** Weapon image condition flags from ghost ImageMask data. */
weaponImageState?: WeaponImageState; weaponImageState?: WeaponImageState;
/** Weapon image state machine states from the ShapeBaseImageData datablock. */ /** Weapon image state machine states from the ShapeBaseImageData datablock. */
@ -125,6 +127,10 @@ export interface DemoEntity {
headPitch?: number; headPitch?: number;
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */ /** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
headYaw?: number; headYaw?: number;
/** Numeric ID of the ExplosionData datablock (for explosion shape rendering). */
explosionDataBlockId?: number;
/** Billboard toward camera (Torque's faceViewer). */
faceViewer?: boolean;
} }
export interface DemoRecording { export interface DemoRecording {
@ -147,6 +153,8 @@ export interface DemoStreamEntity {
playerName?: string; playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */ /** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number }; iffColor?: { r: number; g: number; b: number };
/** Target render flags bitmask from the Target Manager. */
targetRenderFlags?: number;
ghostIndex?: number; ghostIndex?: number;
className?: string; className?: string;
dataBlockId?: number; dataBlockId?: number;

View file

@ -295,6 +295,17 @@ export class EmitterInstance {
if (this.particles.length < this.maxParticles) { if (this.particles.length < this.maxParticles) {
this.addParticle(pos, axis); this.addParticle(pos, axis);
// V12: when overrideAdvances is false, immediately age the newly
// spawned particle by the remaining time in this frame. If that
// exceeds its lifetime, kill it immediately (never rendered).
if (!this.data.overrideAdvances && timeLeft > 0) {
const p = this.particles[this.particles.length - 1];
p.currentAge += timeLeft;
if (p.currentAge >= p.totalLifetime) {
this.particles.pop();
}
}
} }
// Compute next emission time. // Compute next emission time.
@ -310,10 +321,10 @@ export class EmitterInstance {
update(dtMS: number): void { update(dtMS: number): void {
this.emitterAge += dtMS; this.emitterAge += dtMS;
// Check emitter lifetime. // Check emitter lifetime (V12 uses strictly greater).
if ( if (
this.emitterLifetime > 0 && this.emitterLifetime > 0 &&
this.emitterAge >= this.emitterLifetime this.emitterAge > this.emitterLifetime
) { ) {
this.emitterDead = true; this.emitterDead = true;
} }
@ -339,9 +350,9 @@ export class EmitterInstance {
// a = acc - vel*drag - wind*windCoeff + gravity*gravCoeff // a = acc - vel*drag - wind*windCoeff + gravity*gravCoeff
// We skip wind for now (no wind system yet). // We skip wind for now (no wind system yet).
const ax = -p.vel[0] * drag; const ax = p.acc[0] - p.vel[0] * drag;
const ay = -p.vel[1] * drag; const ay = p.acc[1] - p.vel[1] * drag;
const az = -p.vel[2] * drag + GRAVITY_Z * gravCoeff; const az = p.acc[2] - p.vel[2] * drag + GRAVITY_Z * gravCoeff;
// Symplectic Euler: update vel first, then pos with new vel. // Symplectic Euler: update vel first, then pos with new vel.
p.vel[0] += ax * dt; p.vel[0] += ax * dt;
@ -430,14 +441,13 @@ export class EmitterInstance {
ejZ * speed, ejZ * speed,
]; ];
// V12: acc = vel * constantAcceleration (set once, never changes). // V12: acc = vel * constantAcceleration, set once at spawn, applied every frame.
// We fold this into the initial velocity for simplicity since the const ca = pData.constantAcceleration;
// constant acceleration just biases the starting velocity direction. const acc: [number, number, number] = [
// Actually, in V12 acc is a separate constant vector added each frame. vel[0] * ca,
// For faithfulness, we should track it. But since it's constant, we can vel[1] * ca,
// just apply it in the update loop as a per-particle property. vel[2] * ca,
// For now, bake it into the velocity since most Tribes 2 datablocks ];
// have constantAcceleration = 0.
// Particle lifetime with variance. // Particle lifetime with variance.
let lifetime = pData.lifetimeMS; let lifetime = pData.lifetimeMS;
@ -456,6 +466,7 @@ export class EmitterInstance {
this.particles.push({ this.particles.push({
pos: spawnPos, pos: spawnPos,
vel, vel,
acc,
orientDir: [ejX, ejY, ejZ], orientDir: [ejX, ejY, ejZ],
currentAge: 0, currentAge: 0,
totalLifetime: lifetime, totalLifetime: lifetime,

View file

@ -4,6 +4,10 @@ attribute vec4 particleColor;
attribute float particleSize; attribute float particleSize;
attribute float particleSpin; attribute float particleSpin;
attribute vec2 quadCorner; // (-0.5,-0.5) to (0.5,0.5) attribute vec2 quadCorner; // (-0.5,-0.5) to (0.5,0.5)
attribute vec3 orientDir;
uniform bool uOrientParticles;
// cameraPosition is a built-in Three.js uniform.
varying vec2 vUv; varying vec2 vUv;
varying vec4 vColor; varying vec4 vColor;
@ -12,27 +16,47 @@ void main() {
vUv = quadCorner + 0.5; // [0,1] range vUv = quadCorner + 0.5; // [0,1] range
vColor = particleColor; vColor = particleColor;
// Transform particle center to view space for billboarding. if (uOrientParticles) {
vec3 viewPos = (modelViewMatrix * vec4(position, 1.0)).xyz; if (length(orientDir) < 0.0001) {
// V12: don't render oriented particles with zero velocity.
gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
// V12 oriented particle: quad aligned along direction, facing camera.
vec3 worldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vec3 dir = normalize(orientDir);
vec3 dirFromCam = worldPos - cameraPosition;
vec3 crossDir = normalize(cross(dirFromCam, dir));
// Apply spin rotation to quad corner. // V12 maps U along dir (velocity) — match by using quadCorner.x for dir.
float c = cos(particleSpin); vec3 offset = dir * quadCorner.x + crossDir * quadCorner.y;
float s = sin(particleSpin); worldPos += offset * particleSize;
vec2 rotated = vec2(
c * quadCorner.x - s * quadCorner.y,
s * quadCorner.x + c * quadCorner.y
);
// Offset in view space (camera-facing billboard). gl_Position = projectionMatrix * viewMatrix * vec4(worldPos, 1.0);
viewPos.xy += rotated * particleSize; } else {
// Standard camera-facing billboard.
vec3 viewPos = (modelViewMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * vec4(viewPos, 1.0); // Apply spin rotation to quad corner.
float c = cos(particleSpin);
float s = sin(particleSpin);
vec2 rotated = vec2(
c * quadCorner.x - s * quadCorner.y,
s * quadCorner.x + c * quadCorner.y
);
// Offset in view space (camera-facing billboard).
viewPos.xy += rotated * particleSize;
gl_Position = projectionMatrix * vec4(viewPos, 1.0);
}
} }
`; `;
export const particleFragmentShader = /* glsl */ ` export const particleFragmentShader = /* glsl */ `
uniform sampler2D particleTexture; uniform sampler2D particleTexture;
uniform bool hasTexture; uniform bool hasTexture;
uniform float debugOpacity;
varying vec2 vUv; varying vec2 vUv;
varying vec4 vColor; varying vec4 vColor;
@ -44,5 +68,6 @@ void main() {
} else { } else {
gl_FragColor = vColor; gl_FragColor = vColor;
} }
gl_FragColor.a *= debugOpacity;
} }
`; `;

View file

@ -48,6 +48,8 @@ export interface EmitterDataResolved {
export interface Particle { export interface Particle {
pos: [number, number, number]; pos: [number, number, number];
vel: [number, number, number]; vel: [number, number, number];
/** V12: constant acceleration = vel * constantAcceleration, set once at spawn. */
acc: [number, number, number];
orientDir: [number, number, number]; orientDir: [number, number, number];
currentAge: number; currentAge: number;
totalLifetime: number; totalLifetime: number;

View file

@ -305,6 +305,49 @@ export const engineStore = createStore<EngineStoreState>()(
})), })),
); );
// ── Rate-scaled effect clock ──
//
// A monotonic clock that advances by (frameDelta × playbackRate) each frame.
// Components use demoEffectNow() instead of performance.now() so that effect
// timers (explosions, particles, shockwaves, animation threads) automatically
// pause when the demo is paused and speed up / slow down with the playback
// rate. The main DemoPlaybackStreaming component calls advanceEffectClock()
// once per frame.
let _effectClockMs = 0;
/**
* Returns the current effect clock value in milliseconds.
* Analogous to performance.now() but only advances when playing,
* scaled by the playback rate.
*/
export function demoEffectNow(): number {
return _effectClockMs;
}
/**
* Advance the effect clock. Called once per frame from
* DemoPlaybackStreaming before other useFrame callbacks run.
*/
export function advanceEffectClock(deltaSec: number, rate: number): void {
_effectClockMs += deltaSec * rate * 1000;
}
/** Reset the effect clock (call when demo recording changes or stops). */
export function resetEffectClock(): void {
_effectClockMs = 0;
}
// Reset on stop.
engineStore.subscribe(
(state) => state.playback.status,
(status) => {
if (status === "stopped") {
resetEffectClock();
}
},
);
export function useEngineStoreApi() { export function useEngineStoreApi() {
return engineStore; return engineStore;
} }

View file

@ -8,6 +8,9 @@ export type {
export { export {
engineStore, engineStore,
demoEffectNow,
advanceEffectClock,
resetEffectClock,
useEngineSelector, useEngineSelector,
useEngineStoreApi, useEngineStoreApi,
useRuntimeObjectById, useRuntimeObjectById,