diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index 6ebd8f3..64d3892 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -1,10 +1,12 @@ #include "skate3_native_ghost.h" #include +#include #include #include #include #include +#include #include #include #include @@ -25,6 +27,10 @@ #include #include +#if defined(_WIN32) +#include +#endif + // ---- cvars ----------------------------------------------------------------- // The default public relay lives in ghost_proto.h (kGhostDefaultServer); // leave skate3_ghost_server empty to use it. Set it to reach a custom relay @@ -50,6 +56,10 @@ REXCVAR_DEFINE_STRING(skate3_ghost_room, "", "Skate 3", "assigns one and it is shown in the multiplayer menu. " "Players sharing a code see each other's ghosts.") .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_STRING(skate3_ghost_name, "", "Skate 3", + "Display name shown on this player's nameplate above " + "the ghost. Empty = derived from the OS user name.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 15.0, "Skate 3", "Ghost pose broadcast rate in Hz (a full skeleton pose " "is a few KB; 15 Hz is the LAN/internet sweet spot).") @@ -207,6 +217,10 @@ uint8_t g_room_len = 0; size_t g_roster = 0; char g_peer[96] = {}; +// Display names of the room members, keyed by their session id (from the +// latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded. +std::unordered_map> g_roster_names; + // Network thread. std::thread g_thread; std::atomic g_stop{false}; @@ -250,6 +264,42 @@ bool Enabled() { return REXCVAR_GET(skate3_ghost_enable); } bool Debug() { return REXCVAR_GET(skate3_ghost_debug); } +// This player's display name (cvar override or the OS user name). +void CurrentDisplayName(char out[kGhostMaxNameLen], uint8_t* out_len) { + std::string name = REXCVAR_GET(skate3_ghost_name); + if (name.empty()) { + const char* user = nullptr; +#if defined(_WIN32) + static char s_win_user[64] = {}; + if (s_win_user[0] == '\0') { + DWORD n = sizeof(s_win_user); + if (GetUserNameA(s_win_user, &n) == 0) { + std::snprintf(s_win_user, sizeof(s_win_user), "Player"); + } + } + user = s_win_user; +#else + user = std::getenv("USER"); +#endif + if (user != nullptr && user[0] != '\0') { + name = user; + } else { + name = "Player"; + } + } + uint8_t len = 0; + for (char c : name) { + if (len >= kGhostMaxNameLen) { + break; + } + if (c < 0x20 || c == 0x7F) { + continue; // printable ASCII only (payload is a raw byte array) + } + out[len++] = c; + } + *out_len = len; +} + void CopyRoomFromPayload(const char* room, uint8_t len) { std::lock_guard lock(g_mu); g_room_len = std::min(len, kGhostMaxRoomLen); @@ -271,6 +321,9 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) { std::memcpy(jp.room, g_room, g_room_len); jp.room_len = g_room_len; } + // Display name: JOIN carries it so the relay can echo it in the roster; + // the LAN discovery PING carries it too (a host can tag probes). + CurrentDisplayName(jp.name, &jp.name_len); PacketBuf p; BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp)); GhostSockSendTo(sock, to, &p, PacketSize(p)); @@ -422,6 +475,17 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { std::lock_guard lock(g_mu); g_roster = hp.roster_count; g_connected = true; + // Refresh the room members' display names (nameplate labels). + g_roster_names.clear(); + for (uint8_t i = 0; i < hp.roster_count; ++i) { + const RosterEntry& e = hp.roster[i]; + std::array name = {}; + // Reserve one byte for the NUL so the label can always snprintf. + const uint8_t n = + std::min(e.name_len, kGhostMaxNameLen - 1); + std::memcpy(name.data(), e.name, n); + g_roster_names[e.session_id] = name; + } } SetStatusPeer(from); if (Debug()) { @@ -494,6 +558,7 @@ void ResetSession() { g_ghosts.clear(); g_ghost_index.clear(); g_pending_poses.clear(); + g_roster_names.clear(); g_session_public = 0; g_session_lan = 0; g_peer_public = Peer{}; @@ -598,9 +663,11 @@ void GhostNetworkLoop() { last_port = port; } - // Public relay: JOIN every kJoinIntervalSec until HELLO, then send POSE. + // Public relay: JOIN every kJoinIntervalSec (a periodic refresh that also + // keeps the roster/name labels current as members join and leave), and + // reconnect when the HELLO stream goes quiet. if (g_peer_public.configured) { - if (!g_peer_public.joined && now - g_peer_public.last_join >= kJoinIntervalSec) { + if (now - g_peer_public.last_join >= kJoinIntervalSec) { SendJoin(sock, g_peer_public.addr, /*ping=*/false); g_peer_public.last_join = now; } @@ -647,7 +714,7 @@ void GhostNetworkLoop() { // The LAN peer (a discovered host, or our own loopback host): JOIN + POSE. if (g_peer_lan.configured) { - if (!g_peer_lan.joined && now - g_peer_lan.last_join >= kJoinIntervalSec) { + if (now - g_peer_lan.last_join >= kJoinIntervalSec) { SendJoin(sock, g_peer_lan.addr, /*ping=*/false); g_peer_lan.last_join = now; } @@ -869,6 +936,28 @@ bool GetLocalWorld(float world[16]) { return true; } +bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, + size_t name_max) { + std::lock_guard lock(g_mu); + if (index >= g_ghosts.size()) { + return false; + } + const Ghost& gh = g_ghosts[index]; + pos[0] = gh.world[12]; + pos[1] = gh.world[13]; + pos[2] = gh.world[14]; + GhostColor(gh.session_id, color); + if (name != nullptr && name_max > 0) { + const auto it = g_roster_names.find(gh.session_id); + if (it != g_roster_names.end() && it->second[0] != '\0') { + std::snprintf(name, name_max, "%s", it->second.data()); + } else { + std::snprintf(name, name_max, "P%u", gh.session_id); + } + } + return true; +} + // ---- cube geometry ----------------------------------------------------------- // Unit cube centered at the origin, half-extent 0.5, 12 triangles as a // non-indexed triangle list (36 vertices). All attribute lanes except the diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h index 2edd69f..d3e6954 100644 --- a/src/native/skate3_native_ghost.h +++ b/src/native/skate3_native_ghost.h @@ -70,6 +70,12 @@ bool GetGhostPose(uint32_t index, float world[16], float color[3], // Self-check cube at the local captured pose (skate3_ghost_draw_local). bool GetLocalWorld(float world[16]); +// Lightweight per-ghost query for the nameplate overlay: the ghost's world +// position (entity world), marker color, and display name (from the room +// roster, or a session fallback). Returns false if the ghost is gone. +bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, + size_t name_max); + // ---- cube geometry (56-byte records matching kSceneInputLayout) ----------------- struct GhostCubeVertex { float pos[3]; diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index 508321f..d458527 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -23,10 +23,11 @@ namespace skate3::native_ghost { constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) -constexpr uint8_t kGhostVersion = 2; +constexpr uint8_t kGhostVersion = 3; constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoster = 32; +constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII // Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3 // skater skeleton fits comfortably under this; rows beyond the sender's // captured count are identity-padded so every mesh's referenced bones stay @@ -55,18 +56,29 @@ struct alignas(1) PacketHeader { }; // JOIN and PING share the room payload (PING may carry the probing client's -// room so LAN hosts can answer only matching rooms). +// room so LAN hosts can answer only matching rooms); JOIN additionally +// carries the client's display name, which the relay echoes to room members +// in the HELLO roster so nameplates can label every ghost. struct alignas(1) JoinPayload { uint8_t room_len; char room[kGhostMaxRoomLen]; + uint8_t name_len; + char name[kGhostMaxNameLen]; +}; + +// One room member as reported in a HELLO roster: session id + display name. +struct alignas(1) RosterEntry { + uint32_t session_id; + uint8_t name_len; + char name[kGhostMaxNameLen]; }; struct alignas(1) HelloPayload { uint32_t session_id; // our assigned session id (0 = refused) uint8_t room_len; char room[kGhostMaxRoomLen]; - uint8_t roster_count; // other members' session ids in this room - uint32_t roster[kGhostMaxRoster]; + uint8_t roster_count; // other members in this room + RosterEntry roster[kGhostMaxRoster]; }; // Full-pose blob (the character-model path): the sender's skater skeleton, diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index a7b986c..181ed60 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -126,6 +126,12 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, std::memcpy(me->room, jp.room, jp.room_len); me->room_len = jp.room_len; } + // Keep the client's display name (JOIN carries it; PINGs from probes + // may omit it, so only overwrite with a non-empty name). + if (jp.name_len != 0 && jp.name_len <= kGhostMaxNameLen) { + std::memcpy(me->name, jp.name, jp.name_len); + me->name_len = jp.name_len; + } // Empty room: assign one (uppercase alnum, 4 chars, seeded by the // session id and the wall of the receiving thread). if (me->room_len == 0) { @@ -152,7 +158,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, if (&c != me && c.room_len == me->room_len && RoomEquals(c.room, c.room_len, me->room, me->room_len) && hp.roster_count < kGhostMaxRoster) { - hp.roster[hp.roster_count++] = c.session_id; + RosterEntry& entry = hp.roster[hp.roster_count++]; + entry.session_id = c.session_id; + entry.name_len = c.name_len; + std::memcpy(entry.name, c.name, c.name_len); } } BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); @@ -168,7 +177,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, if (&c != me && c.room_len == me->room_len && RoomEquals(c.room, c.room_len, me->room, me->room_len) && hp.roster_count < kGhostMaxRoster) { - hp.roster[hp.roster_count++] = c.session_id; + RosterEntry& entry = hp.roster[hp.roster_count++]; + entry.session_id = c.session_id; + entry.name_len = c.name_len; + std::memcpy(entry.name, c.name, c.name_len); } } BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); diff --git a/src/native/skate3_native_ghost_relay.h b/src/native/skate3_native_ghost_relay.h index 9e55eed..bb67bb5 100644 --- a/src/native/skate3_native_ghost_relay.h +++ b/src/native/skate3_native_ghost_relay.h @@ -59,6 +59,8 @@ class GhostRelay { uint32_t session_id = 0; char room[kGhostMaxRoomLen] = {}; uint8_t room_len = 0; + char name[kGhostMaxNameLen] = {}; + uint8_t name_len = 0; double last_seen = 0.0; }; mutable std::mutex mu_; diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index bf66071..ef89153 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -683,6 +683,8 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) { // skate3_native_render_mode_indicator shows it live). Input-transparent, // so it never affects cursor or focus handling. render_mode_indicator_ = std::make_unique(drawer); + ghost_nameplate_overlay_ = + std::make_unique(drawer); rex::ui::RegisterBind("bind_skate3_menu", "Escape", "Skate 3 settings", [this] { // Escape backs out of the settings screen level by level (rows -> // category rail -> closed); when the screen is closed it opens it. @@ -835,6 +837,7 @@ void Skate3BaseApp::OnShutdown() { simple_settings_dialog_.reset(); native_debug_dialog_.reset(); ghost_dialog_.reset(); + ghost_nameplate_overlay_.reset(); render_mode_indicator_.reset(); } diff --git a/src/skate3_app_common.h b/src/skate3_app_common.h index c24f10e..a2bb75d 100644 --- a/src/skate3_app_common.h +++ b/src/skate3_app_common.h @@ -62,6 +62,7 @@ class Skate3BaseApp : public rex::ReXApp { std::unique_ptr simple_settings_dialog_; std::unique_ptr native_debug_dialog_; std::unique_ptr ghost_dialog_; + std::unique_ptr ghost_nameplate_overlay_; std::unique_ptr render_mode_indicator_; bool recipe_overlay_installed_ = false; bool big_device_aliases_installed_ = false; diff --git a/src/skate3_native_ghost_dialog.cpp b/src/skate3_native_ghost_dialog.cpp index 9f0a38b..9018c91 100644 --- a/src/skate3_native_ghost_dialog.cpp +++ b/src/skate3_native_ghost_dialog.cpp @@ -1,5 +1,6 @@ #include "skate3_native_ghost_dialog.h" +#include #include #include @@ -9,11 +10,13 @@ #include #include "native/skate3_native_ghost.h" +#include "skate3_native_scene.h" REXCVAR_DECLARE(bool, skate3_ghost_enable); REXCVAR_DECLARE(std::string, skate3_ghost_server); REXCVAR_DECLARE(int32_t, skate3_ghost_port); REXCVAR_DECLARE(std::string, skate3_ghost_room); +REXCVAR_DECLARE(std::string, skate3_ghost_name); REXCVAR_DECLARE(double, skate3_ghost_rate); REXCVAR_DECLARE(bool, skate3_ghost_draw_local); @@ -45,6 +48,8 @@ void GhostDialog::SyncBuffersFromCvars() { std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str()); const std::string server = REXCVAR_GET(skate3_ghost_server); std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str()); + const std::string name = REXCVAR_GET(skate3_ghost_name); + std::snprintf(name_input_, sizeof(name_input_), "%s", name.c_str()); synced_ = true; } @@ -94,6 +99,17 @@ void GhostDialog::OnDraw(ImGuiIO& io) { ImGui::Separator(); + ImGui::Text("Display name"); + if (ImGui::InputText("##ghost_name", name_input_, sizeof(name_input_), + ImGuiInputTextFlags_EnterReturnsTrue)) { + REXCVAR_SET(skate3_ghost_name, std::string(name_input_)); + ImGui::SetKeyboardFocusHere(-1); + } + ImGui::TextDisabled("Shown on your nameplate above the ghost. Empty = " + "your OS user name."); + + ImGui::Separator(); + ImGui::Text("Relay server (optional)"); ImGui::PushItemWidth(-1.0f); if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_), @@ -144,4 +160,59 @@ void GhostDialog::OnDraw(ImGuiIO& io) { } } +void GhostNameplateOverlay::OnDraw(ImGuiIO& io) { + if (!REXCVAR_GET(skate3_ghost_enable)) { + return; + } + if (io.DisplaySize.x < 16.0f || io.DisplaySize.y < 16.0f) { + return; + } + float vp[16]; + float cam[3]; + if (!skate3::native_scene::GetPublishedCamera(vp, cam)) { + return; + } + const uint32_t count = skate3::native_ghost::GhostCount(); + if (count == 0) { + return; + } + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + const float w = io.DisplaySize.x; + const float h = io.DisplaySize.y; + for (uint32_t i = 0; i < count; ++i) { + float pos[3]; + float color[3]; + char name[32]; + if (!skate3::native_ghost::GetGhostLabel(i, pos, color, name, + sizeof(name))) { + continue; + } + // Project the point ~1.75 m above the entity origin (roughly the head). + const float p[3] = {pos[0], pos[1] + 1.75f, pos[2]}; + float c[4]; + for (int r = 0; r < 4; ++r) { + c[r] = p[0] * vp[r] + p[1] * vp[4 + r] + p[2] * vp[8 + r] + vp[12 + r]; + } + if (c[3] < 0.01f) { + continue; // behind the camera + } + const float ndc_x = c[0] / c[3]; + const float ndc_y = c[1] / c[3]; + if (ndc_x < -1.6f || ndc_x > 1.6f || ndc_y < -1.6f || ndc_y > 1.6f) { + continue; + } + const float sx = (ndc_x * 0.5f + 0.5f) * w; + const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * h; + const ImU32 text_col = + IM_COL32(int(color[0] * 255.0f), int(color[1] * 255.0f), + int(color[2] * 255.0f), 235); + const ImVec2 text_size = ImGui::CalcTextSize(name); + // Small dark backdrop so the label stays readable over the world. + dl->AddRectFilled(ImVec2(sx - 4.0f, sy - 14.0f), + ImVec2(sx + text_size.x + 4.0f, sy + 2.0f), + IM_COL32(0, 0, 0, 140)); + dl->AddText(ImVec2(sx, sy - 14.0f), text_col, name); + } +} + } // namespace skate3 \ No newline at end of file diff --git a/src/skate3_native_ghost_dialog.h b/src/skate3_native_ghost_dialog.h index f3d2015..ec9355d 100644 --- a/src/skate3_native_ghost_dialog.h +++ b/src/skate3_native_ghost_dialog.h @@ -1,12 +1,13 @@ #pragma once -// Ghost multiplayer menu: enable toggle, room-code entry, relay override and -// a live connection status readout. A player-facing dialog (bound to M by -// default in skate3_app_common.cpp); everything it edits is a hot-reload -// cvar in skate3_native_ghost.cpp. +// Ghost multiplayer UI: the multiplayer menu (GhostDialog, bound to M) and +// the always-present nameplate overlay (GhostNameplateOverlay) that labels +// each remote ghost with its owner's display name. #include +#include "native/skate3_native_ghost_proto.h" + namespace skate3 { class GhostDialog final : public rex::ui::ImGuiDialog { @@ -28,7 +29,20 @@ class GhostDialog final : public rex::ui::ImGuiDialog { bool synced_ = false; char room_input_[16] = {}; char server_input_[128] = {}; + char name_input_[skate3::native_ghost::kGhostMaxNameLen + 1] = {}; int rate_int_ = 20; }; +// Draws a small colored nameplate above every remote ghost whenever ghost +// multiplayer is enabled. Registers like the render-mode indicator (always +// present, draws only while active). +class GhostNameplateOverlay final : public rex::ui::ImGuiDialog { + public: + explicit GhostNameplateOverlay(rex::ui::ImGuiDrawer* drawer) + : ImGuiDialog(drawer) {} + + protected: + void OnDraw(ImGuiIO& io) override; +}; + } // namespace skate3 \ No newline at end of file diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index d5d38c3..bc68aaf 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -2756,6 +2756,16 @@ bool ToggleSceneEnabled() { return enabled; } +bool GetPublishedCamera(float view_proj[16], float cam_pos[3]) { + std::lock_guard lock(g_scene_mutex); + if (!g_scene) { + return false; + } + std::memcpy(view_proj, g_scene->view_proj, sizeof(float) * 16); + std::memcpy(cam_pos, g_scene->cam_pos, sizeof(float) * 3); + return true; +} + void OnRegisterTexture(uint64_t guid, uint32_t texture) { if (texture == 0) { return; diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index f90267e..4d9652d 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -689,6 +689,12 @@ void Install(); // layer was not enabled at boot. bool ToggleSceneEnabled(); +// Snapshot of the latest published frame's camera (smoothed view-proj + +// world position). Thread-safe (any thread); returns false before the first +// publish. Used by the ghost nameplate overlay to project world positions +// into screen space on the UI thread. +bool GetPublishedCamera(float view_proj[16], float cam_pos[3]); + // True once the scene renderer has hit an unrecoverable pipeline/resource // failure and yields to the emulated output until re-enabled. Surfaced so // the render-mode indicator can show the degraded state even though the diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index 1f0ad0f..946e286 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -10360,6 +10360,11 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat // skinned non-ropa, non-LivingWorld items), found the same way the // ghost capture on the game thread finds the body. std::vector player_pieces; + // The local player's cloth garments (rigid character.cloth_ropa items: + // CPU-sim deformed ROOT-LOCAL VB, drawn with the entity world affine). + // Cloned for ghosts and rendered with the REMOTE entity world so the + // ghost keeps its tee/pants, draped to its root frame. + std::vector ghost_cloth; { const DrawItem* body = nullptr; uint32_t best_bones = 0; @@ -10404,6 +10409,19 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat player_pieces.push_back(&it); } } + // Cloth garments near the body (world translation = the entity + // position; only the rigid state has a root-local deformed VB). + for (const DrawItem& it : scene.items) { + if (!it.ropa || !it.bones.empty()) { + continue; + } + const float px = it.world[12], py = it.world[13], pz = it.world[14]; + const float dx = px - body_p[0], dy = py - body_p[1], + dz = pz - body_p[2]; + if (dx * dx + dy * dy + dz * dz <= 6.25f) { + ghost_cloth.push_back(&it); + } + } } } @@ -10455,7 +10473,8 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat cmd->Draw(kCubeVerts, 0); }; - const auto draw_model = [&](const float* pose_rows, uint16_t pose_count) { + const auto draw_model = [&](const float* pose_rows, uint16_t pose_count, + const float* ghost_world) { if (player_pieces.empty()) { return; } @@ -10489,6 +10508,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat g.lw_alpha = -1.0f; timed_draw(g); } + // Cloth: clone the local player's garments (root-local deformed VB) + // and draw them with the REMOTE entity world, so each ghost wears + // this player's tee/pants draped in its own root frame. + for (const DrawItem* cloth : ghost_cloth) { + DrawItem g = *cloth; + std::memcpy(g.world, ghost_world, sizeof(g.world)); + g.bones.clear(); + g.skinned = false; + g.pending = false; + g.selected = false; + g.retained = false; + g.dyn_entity = false; + g.unlit = false; + g.transparent = false; + g.water = false; + g.lw_alpha = -1.0f; + timed_draw(g); + } }; float ghost_world[16]; @@ -10501,7 +10538,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat uint16_t(skate3::native_ghost::kGhostMaxBones), pose_scratch, &ghost_rows)) { if (ghost_rows != 0) { - draw_model(pose_scratch, ghost_rows); + draw_model(pose_scratch, ghost_rows, ghost_world); continue; } draw_cube(ghost_world, ghost_color);