@@ -30,6 +30,7 @@ import { EscapeMenu } from "./hud/EscapeMenu";
3030import { ConnectionIndicator } from "./hud/ConnectionIndicator" ;
3131import { NotificationContainer } from "@/ui/components" ;
3232import { Disconnected , KickedOverlay , DeathScreen } from "./hud/overlays" ;
33+ import { SpectatorOverlay } from "./SpectatorOverlay" ;
3334import {
3435 COLORS ,
3536 spacing ,
@@ -57,6 +58,14 @@ export function CoreUI({ world }: { world: ClientWorld }) {
5758 return config ?. mode === "spectator" ;
5859 } ) ( ) ;
5960
61+ // The entity being followed in spectator mode — prefer config, fall back to URL param
62+ const spectatorFollowId = ( ( ) => {
63+ const config = window . __HYPERSCAPE_CONFIG__ ;
64+ if ( config ?. followEntity ) return config . followEntity ;
65+ if ( config ?. characterId ) return config . characterId ;
66+ return new URLSearchParams ( window . location . search ) . get ( "followEntity" ) ;
67+ } ) ( ) ;
68+
6069 // Presentation gating flags
6170 const [ playerReady , setPlayerReady ] = useState ( ( ) => ! ! world . entities . player ) ;
6271 const [ physReady , setPhysReady ] = useState ( false ) ;
@@ -67,6 +76,14 @@ export function CoreUI({ world }: { world: ClientWorld }) {
6776 const [ disconnected , setDisconnected ] = useState ( false ) ;
6877 const [ kicked , setKicked ] = useState < string | null > ( null ) ;
6978 const [ characterFlowActive , setCharacterFlowActive ] = useState ( false ) ;
79+ const [ characterList , setCharacterList ] = useState <
80+ Array < { id : string ; name : string ; level ?: number } >
81+ > ( ( ) => {
82+ const net = world . network as {
83+ lastCharacterList ?: Array < { id : string ; name : string ; level ?: number } > ;
84+ } ;
85+ return net . lastCharacterList || [ ] ;
86+ } ) ;
7087 const [ deathScreen , setDeathScreen ] = useState < {
7188 message : string ;
7289 killedBy : string ;
@@ -168,13 +185,24 @@ export function CoreUI({ world }: { world: ClientWorld }) {
168185 world . on ( EventType . UI_DEATH_SCREEN_CLOSE , handleDeathScreenClose ) ;
169186 // Character selection flow (server-flagged)
170187 // Define named handlers for proper cleanup (anonymous functions don't work with off())
171- const handleCharacterList = ( ) : void => setCharacterFlowActive ( true ) ;
188+ const handleCharacterList = ( data : unknown ) : void => {
189+ const listData = data as {
190+ characters ?: Array < { id : string ; name : string ; level ?: number } > ;
191+ } ;
192+ setCharacterList ( listData . characters || [ ] ) ;
193+ setCharacterFlowActive ( true ) ;
194+ } ;
172195 const handleCharacterSelected = ( ) : void => setCharacterFlowActive ( false ) ;
173196 world . on ( "character:list" , handleCharacterList ) ;
174197 world . on ( "character:selected" , handleCharacterSelected ) ;
175198 // If the packet arrived before UI mounted, consult network cache
176- const network = world . network as { lastCharacterList ?: unknown [ ] } ;
177- if ( network . lastCharacterList ) setCharacterFlowActive ( true ) ;
199+ const network = world . network as {
200+ lastCharacterList ?: Array < { id : string ; name : string ; level ?: number } > ;
201+ } ;
202+ if ( network . lastCharacterList ) {
203+ setCharacterList ( network . lastCharacterList ) ;
204+ setCharacterFlowActive ( true ) ;
205+ }
178206
179207 return ( ) => {
180208 // Clean up the ready timeout if it exists
@@ -427,16 +455,17 @@ export function CoreUI({ world }: { world: ClientWorld }) {
427455 < div id = "core-ui-portal" />
428456 </ div >
429457 { /* Non-scaled overlays - full screen elements */ }
430- { ! ready && (
431- < LoadingScreen
432- world = { world }
433- message = {
434- characterFlowActive ? "Entering world..." : "Loading world..."
435- }
436- />
458+ { ! ready && ! characterFlowActive && (
459+ < LoadingScreen world = { world } message = "Loading world..." />
460+ ) }
461+ { ! ready && characterFlowActive && (
462+ < CharacterSelectOverlay world = { world } characters = { characterList } />
437463 ) }
438464 { kicked && < KickedOverlay code = { kicked } /> }
439465 { deathScreen && < DeathScreen data = { deathScreen } world = { world } /> }
466+ { ready && isSpectatorMode && spectatorFollowId && (
467+ < SpectatorOverlay characterId = { spectatorFollowId } />
468+ ) }
440469 </ main >
441470 </ ChatProvider >
442471 ) ;
@@ -722,6 +751,273 @@ function ToastMsg({ text }: { text: string }) {
722751 ) ;
723752}
724753
754+ type CharEntry = { id : string ; name : string ; level ?: number } ;
755+
756+ function CharacterSelectOverlay ( {
757+ world,
758+ characters,
759+ } : {
760+ world : ClientWorld ;
761+ characters : CharEntry [ ] ;
762+ } ) {
763+ const [ showCreate , setShowCreate ] = useState ( false ) ;
764+ const [ newName , setNewName ] = useState ( "" ) ;
765+ const [ submitting , setSubmitting ] = useState ( false ) ;
766+ const [ error , setError ] = useState ( "" ) ;
767+
768+ const network = world . network as {
769+ requestCharacterSelect : ( id : string ) => void ;
770+ requestCharacterCreate : ( name : string ) => void ;
771+ requestEnterWorld : ( ) => void ;
772+ } ;
773+
774+ // Auto-select newly created character
775+ useEffect ( ( ) => {
776+ const onCreated = ( data : unknown ) => {
777+ const created = data as { id : string ; name : string } ;
778+ if ( created . id ) {
779+ handleSelect ( created . id ) ;
780+ }
781+ } ;
782+ world . on ( EventType . CHARACTER_CREATED , onCreated ) ;
783+ return ( ) => world . off ( EventType . CHARACTER_CREATED , onCreated ) ;
784+ // eslint-disable-next-line react-hooks/exhaustive-deps
785+ } , [ world ] ) ;
786+
787+ const handleSelect = ( id : string ) => {
788+ if ( typeof sessionStorage !== "undefined" ) {
789+ sessionStorage . setItem ( "selectedCharacterId" , id ) ;
790+ }
791+ network . requestCharacterSelect ( id ) ;
792+ network . requestEnterWorld ( ) ;
793+ } ;
794+
795+ const handleCreate = ( ) => {
796+ const name = newName . trim ( ) ;
797+ if ( ! name ) {
798+ setError ( "Name cannot be empty." ) ;
799+ return ;
800+ }
801+ if ( name . length < 2 || name . length > 20 ) {
802+ setError ( "Name must be 2–20 characters." ) ;
803+ return ;
804+ }
805+ setSubmitting ( true ) ;
806+ setError ( "" ) ;
807+ network . requestCharacterCreate ( name ) ;
808+ // Server responds with character:created → auto-selects via the effect above
809+ } ;
810+
811+ return (
812+ < div
813+ className = "absolute inset-0 flex items-center justify-center pointer-events-auto"
814+ style = { { background : "rgba(0,0,0,0.85)" , zIndex : 1000 } }
815+ >
816+ < div
817+ style = { {
818+ background : COLORS . BG_SOLID ,
819+ border : `1px solid ${ COLORS . BORDER_SECONDARY } ` ,
820+ borderRadius : borderRadius . xl ,
821+ boxShadow : shadows . panel ,
822+ padding : spacing [ "2xl" ] ,
823+ minWidth : "320px" ,
824+ maxWidth : "480px" ,
825+ width : "90%" ,
826+ } }
827+ >
828+ < h2
829+ style = { {
830+ color : COLORS . TEXT_PRIMARY ,
831+ fontFamily : typography . fontFamily . heading ,
832+ fontSize : typography . fontSize [ "2xl" ] ,
833+ fontWeight : typography . fontWeight . bold ,
834+ marginBottom : spacing . lg ,
835+ textAlign : "center" ,
836+ } }
837+ >
838+ { showCreate ? "Create Character" : "Select Character" }
839+ </ h2 >
840+
841+ { ! showCreate && (
842+ < >
843+ < div
844+ style = { {
845+ display : "flex" ,
846+ flexDirection : "column" ,
847+ gap : spacing . sm ,
848+ marginBottom : spacing . lg ,
849+ } }
850+ >
851+ { characters . length === 0 && (
852+ < p
853+ style = { {
854+ color : COLORS . TEXT_SECONDARY ,
855+ textAlign : "center" ,
856+ fontSize : typography . fontSize . sm ,
857+ marginBottom : spacing . sm ,
858+ } }
859+ >
860+ No characters yet. Create one to begin!
861+ </ p >
862+ ) }
863+ { characters . map ( ( char ) => (
864+ < button
865+ key = { char . id }
866+ onClick = { ( ) => handleSelect ( char . id ) }
867+ style = { {
868+ background : "rgba(255,255,255,0.05)" ,
869+ border : `1px solid ${ COLORS . BORDER_SECONDARY } ` ,
870+ borderRadius : borderRadius . md ,
871+ padding : `${ spacing . md } ${ spacing . lg } ` ,
872+ color : COLORS . TEXT_PRIMARY ,
873+ fontFamily : typography . fontFamily . body ,
874+ fontSize : typography . fontSize . base ,
875+ cursor : "pointer" ,
876+ textAlign : "left" ,
877+ display : "flex" ,
878+ justifyContent : "space-between" ,
879+ alignItems : "center" ,
880+ transition : "background 0.15s" ,
881+ } }
882+ onMouseEnter = { ( e ) => {
883+ ( e . currentTarget as HTMLButtonElement ) . style . background =
884+ "rgba(255,255,255,0.12)" ;
885+ } }
886+ onMouseLeave = { ( e ) => {
887+ ( e . currentTarget as HTMLButtonElement ) . style . background =
888+ "rgba(255,255,255,0.05)" ;
889+ } }
890+ >
891+ < span style = { { fontWeight : typography . fontWeight . medium } } >
892+ { char . name }
893+ </ span >
894+ { char . level !== undefined && (
895+ < span
896+ style = { {
897+ color : COLORS . TEXT_SECONDARY ,
898+ fontSize : typography . fontSize . sm ,
899+ } }
900+ >
901+ Lv { char . level }
902+ </ span >
903+ ) }
904+ </ button >
905+ ) ) }
906+ </ div >
907+ < button
908+ onClick = { ( ) => setShowCreate ( true ) }
909+ style = { {
910+ width : "100%" ,
911+ background : "rgba(99,102,241,0.2)" ,
912+ border : `1px solid rgba(99,102,241,0.5)` ,
913+ borderRadius : borderRadius . md ,
914+ padding : `${ spacing . sm } ${ spacing . lg } ` ,
915+ color : COLORS . TEXT_PRIMARY ,
916+ fontFamily : typography . fontFamily . body ,
917+ fontSize : typography . fontSize . base ,
918+ cursor : "pointer" ,
919+ fontWeight : typography . fontWeight . medium ,
920+ } }
921+ >
922+ + New Character
923+ </ button >
924+ </ >
925+ ) }
926+
927+ { showCreate && (
928+ < >
929+ < input
930+ autoFocus
931+ type = "text"
932+ value = { newName }
933+ onChange = { ( e ) => setNewName ( e . target . value ) }
934+ onKeyDown = { ( e ) => {
935+ if ( e . key === "Enter" ) handleCreate ( ) ;
936+ } }
937+ placeholder = "Enter character name"
938+ maxLength = { 20 }
939+ disabled = { submitting }
940+ style = { {
941+ width : "100%" ,
942+ background : "rgba(255,255,255,0.07)" ,
943+ border : `1px solid ${ error ? "rgba(239,68,68,0.7)" : COLORS . BORDER_SECONDARY } ` ,
944+ borderRadius : borderRadius . md ,
945+ padding : `${ spacing . md } ${ spacing . lg } ` ,
946+ color : COLORS . TEXT_PRIMARY ,
947+ fontFamily : typography . fontFamily . body ,
948+ fontSize : typography . fontSize . base ,
949+ outline : "none" ,
950+ marginBottom : spacing . sm ,
951+ boxSizing : "border-box" ,
952+ } }
953+ />
954+ { error && (
955+ < p
956+ style = { {
957+ color : "rgba(239,68,68,0.9)" ,
958+ fontSize : typography . fontSize . sm ,
959+ marginBottom : spacing . sm ,
960+ } }
961+ >
962+ { error }
963+ </ p >
964+ ) }
965+ < div
966+ style = { {
967+ display : "flex" ,
968+ gap : spacing . sm ,
969+ marginTop : spacing . sm ,
970+ } }
971+ >
972+ < button
973+ onClick = { ( ) => {
974+ setShowCreate ( false ) ;
975+ setNewName ( "" ) ;
976+ setError ( "" ) ;
977+ } }
978+ disabled = { submitting }
979+ style = { {
980+ flex : 1 ,
981+ background : "rgba(255,255,255,0.05)" ,
982+ border : `1px solid ${ COLORS . BORDER_SECONDARY } ` ,
983+ borderRadius : borderRadius . md ,
984+ padding : spacing . sm ,
985+ color : COLORS . TEXT_SECONDARY ,
986+ fontFamily : typography . fontFamily . body ,
987+ fontSize : typography . fontSize . base ,
988+ cursor : "pointer" ,
989+ } }
990+ >
991+ Back
992+ </ button >
993+ < button
994+ onClick = { handleCreate }
995+ disabled = { submitting || ! newName . trim ( ) }
996+ style = { {
997+ flex : 2 ,
998+ background : submitting
999+ ? "rgba(99,102,241,0.1)"
1000+ : "rgba(99,102,241,0.35)" ,
1001+ border : `1px solid rgba(99,102,241,0.5)` ,
1002+ borderRadius : borderRadius . md ,
1003+ padding : spacing . sm ,
1004+ color : COLORS . TEXT_PRIMARY ,
1005+ fontFamily : typography . fontFamily . body ,
1006+ fontSize : typography . fontSize . base ,
1007+ cursor : submitting ? "default" : "pointer" ,
1008+ fontWeight : typography . fontWeight . medium ,
1009+ } }
1010+ >
1011+ { submitting ? "Creating..." : "Create" }
1012+ </ button >
1013+ </ div >
1014+ </ >
1015+ ) }
1016+ </ div >
1017+ </ div >
1018+ ) ;
1019+ }
1020+
7251021function TouchBtns ( { world } : { world : ClientWorld } ) {
7261022 const theme = useThemeStore ( ( s ) => s . theme ) ;
7271023 const [ isAction , setIsAction ] = useState ( ( ) => {
0 commit comments