diff --git a/lib-src/enigma-core/ecl_buffer.cc b/lib-src/enigma-core/ecl_buffer.cc index fdc2b524b..2e17d7427 100644 --- a/lib-src/enigma-core/ecl_buffer.cc +++ b/lib-src/enigma-core/ecl_buffer.cc @@ -16,6 +16,7 @@ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "ecl_buffer.hh" +#include #include using namespace ecl; @@ -62,8 +63,8 @@ char* Buffer::get_rspace(size_t len) { int Buffer::read() { if (good() && rpos < buf + sz) return *rpos++; - else - return -1; + iostate = State(iostate | FAILBIT | EOFBIT); + return -1; } Buffer& Buffer::write(char c) { @@ -225,56 +226,37 @@ Buffer& ecl::write(Buffer& buf, Uint64 lvar) { */ /* NOTE: we reinterpret floats and doubles as Uint32/Uint64, -** and byte-swap them like integers. -** This is how quake does it, too (except they only use floats). -** I couldn't believe that this worked, so I checked it out -** (PPC <-> x86), and it does. The upside (over multiplying -** with 2048 and converting to int, as it was before) is that -** we have the same rep on both machines, possibly leading to -** less desynchronisation in network games. +** and byte-swap them like integers, so we have the same wire rep +** on both machines. memcpy is the idiomatic spelling — the optimizer +** turns it into a register move — and avoids the type-pun-via-union +** UB. */ Buffer& ecl::read(Buffer& buf, float& dvar) { - union { - float f; - Uint32 a; - } t; - if (buf >> t.a) - dvar = t.f; + Uint32 a; + if (buf >> a) + std::memcpy(&dvar, &a, sizeof dvar); return buf; } Buffer& ecl::write(Buffer& buf, float dvar) { - union { - float f; - Uint32 a; - } t; - t.f = dvar; - write(buf, t.a); + Uint32 a; + std::memcpy(&a, &dvar, sizeof dvar); + write(buf, a); return buf; } -/* - ** === Read and write doubles === - */ - Buffer& ecl::read(Buffer& buf, double& dvar) { - union { - double d; - Uint64 a; - } t; - if (buf >> t.a) - dvar = t.d; + Uint64 a; + if (buf >> a) + std::memcpy(&dvar, &a, sizeof dvar); return buf; } Buffer& ecl::write(Buffer& buf, double dvar) { - union { - double d; - Uint64 a; - } t; - t.d = dvar; - write(buf, t.a); + Uint64 a; + std::memcpy(&a, &dvar, sizeof dvar); + write(buf, a); return buf; } diff --git a/lib-src/enigma-core/ecl_buffer.hh b/lib-src/enigma-core/ecl_buffer.hh index fda6a3a3b..111a21a91 100644 --- a/lib-src/enigma-core/ecl_buffer.hh +++ b/lib-src/enigma-core/ecl_buffer.hh @@ -33,6 +33,7 @@ public: void clear() { sz = 0; rpos = wpos = buf; + iostate = GOODBIT; } void assign(char* data, size_t size); diff --git a/src/Inventory.cc b/src/Inventory.cc index 835286c86..2ab0eef26 100644 --- a/src/Inventory.cc +++ b/src/Inventory.cc @@ -36,7 +36,7 @@ typedef std::vector ItemList; unsigned const Inventory::max_items = 12; -Inventory::Inventory() : m_items () { +Inventory::Inventory() : m_items(), ownerId(-1) { } diff --git a/src/Inventory.hh b/src/Inventory.hh index ca44a36ed..7b74655ea 100644 --- a/src/Inventory.hh +++ b/src/Inventory.hh @@ -50,6 +50,7 @@ namespace enigma // ---------- Methods ---------- void assignOwner(int playerId); + int getOwner() const { return ownerId; } //! The number of items currently in the inventory size_t size() const; diff --git a/src/MouseCursor.cc b/src/MouseCursor.cc index 8fd5a93ba..4d4361f3e 100644 --- a/src/MouseCursor.cc +++ b/src/MouseCursor.cc @@ -74,6 +74,16 @@ void MouseCursor::hide() { } } +void MouseCursor::recapture_background() { + if (visible > 0) { + if (!background) + init_bg(); + else + grab_bg(); + changed = true; + } +} + Rect MouseCursor::get_rect() const { return Rect(x - hotx, y - hoty, cursor->width(), cursor->height()); } diff --git a/src/MouseCursor.hh b/src/MouseCursor.hh index 3f12ee9ed..43bacc0a6 100644 --- a/src/MouseCursor.hh +++ b/src/MouseCursor.hh @@ -38,6 +38,11 @@ public: void draw(); // Draw cursor if visible void show(); void hide(); + // Re-capture the screen pixels under the cursor without first + // restoring the (possibly stale) saved copy. Use this after a + // screen-wide repaint (e.g. menu->game transition) so the next + // cursor move doesn't restore stale pixels. + void recapture_background(); ecl::Rect get_rect() const; ecl::Rect get_oldrect() const; diff --git a/src/Object.cc b/src/Object.cc index fcb0ef726..0869da880 100644 --- a/src/Object.cc +++ b/src/Object.cc @@ -98,6 +98,14 @@ int Object::getId() const { return id; } +int Object::getNextIdSnapshot() { + return next_id; +} + +void Object::setNextId(int v) { + next_id = v; +} + std::string Object::getKind() const { return ObjectValidator::instance()->getKind(this); } diff --git a/src/Object.hh b/src/Object.hh index f86813a8d..44cfce56e 100644 --- a/src/Object.hh +++ b/src/Object.hh @@ -94,6 +94,12 @@ public: static Object *getObject(int id); int getId() const; + // Snapshot/restore the global id counter. Used by the LAN + // protocol's SV_ACTOR_ADDED dispatcher to land each new actor on + // the host's id, so subsequent SV_ACTOR_MOVED packets resolve. + static int getNextIdSnapshot(); + static void setNextId(int v); + /* ---------- depreceated methods ---------- */ const AttribMap &get_attribs() const { diff --git a/src/SoundEffectManager.cc b/src/SoundEffectManager.cc index ce8a25cdf..f86180d25 100644 --- a/src/SoundEffectManager.cc +++ b/src/SoundEffectManager.cc @@ -77,11 +77,13 @@ void sound::DefineSoundEffect(std::string soundset_key, std::string name, std::s bool sound::EmitSoundEvent (const std::string &eventname, const ecl::V2 &pos, double volume, bool force_global) { + enigma::client::NotifySound(eventname, pos, volume, force_global); return SoundEffectManager::instance()->emitSoundEvent(eventname, pos, volume, force_global); } bool sound::EmitSoundEventGlobal (const std::string &eventname, double volume) { + enigma::client::NotifySound(eventname, ecl::V2(), volume, true); return SoundEffectManager::instance()->emitSoundEvent(eventname, ecl::V2(), volume, true); } diff --git a/src/actors.cc b/src/actors.cc index b1194917e..a5e3c08d3 100644 --- a/src/actors.cc +++ b/src/actors.cc @@ -19,6 +19,7 @@ */ #include "errors.hh" +#include "client.hh" #include "enigma.hh" #include "player.hh" #include "Inventory.hh" @@ -312,6 +313,7 @@ void Actor::move_screen() { void Actor::set_model(const std::string &name) { m_sprite.replace_model(display::MakeModel(name)); + client::NotifyActorSpriteChanged(getId(), name); } void Actor::animcb() { diff --git a/src/actors.hh b/src/actors.hh index bdd37d884..990502b8d 100644 --- a/src/actors.hh +++ b/src/actors.hh @@ -155,6 +155,7 @@ public: virtual void move_screen(); void warp(const ecl::V2 &newpos); bool sound_event(const char *name, double vol = 1.0); + void set_model(const std::string &modelname); void respawn(); void set_respawnpos(const ecl::V2 &p); @@ -196,7 +197,6 @@ protected: virtual Object::ObjectType getObjectType() const override { return Object::ACTOR; } Actor(const ActorTraits &tr); - void set_model(const std::string &modelname); void set_anim(const std::string &modelname); display::SpriteHandle &get_sprite() { return m_sprite; } diff --git a/src/client.cc b/src/client.cc index 7dea77f52..fd2c9ae83 100644 --- a/src/client.cc +++ b/src/client.cc @@ -39,6 +39,7 @@ #include "lev/Proxy.hh" #include "lev/RatingManager.hh" #include "lev/ScoreManager.hh" +#include "netgame.hh" #include "ecl_font.hh" #include "ecl_sdl.hh" @@ -47,8 +48,10 @@ #include "enet/enet.h" #include "enet_ver.hh" +#include #include #include +#include #include "client_internal.hh" @@ -100,8 +103,79 @@ namespace { Client client_instance; const char HSEP = '^'; // history separator (use character that user cannot use) +std::vector event_sinks; + } // namespace +void RegisterEventSink(EventSink *sink) { + if (sink && std::find(event_sinks.begin(), event_sinks.end(), sink) == event_sinks.end()) + event_sinks.push_back(sink); +} + +void UnregisterEventSink(EventSink *sink) { + auto it = std::find(event_sinks.begin(), event_sinks.end(), sink); + if (it != event_sinks.end()) + event_sinks.erase(it); +} + +void NotifyActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) { + for (auto *s : event_sinks) s->OnActorMoved(object_id, pos, vel); +} + +void NotifyActorSpriteChanged(int object_id, const std::string &model_name) { + for (auto *s : event_sinks) s->OnActorSpriteChanged(object_id, model_name); +} + +void NotifyActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) { + for (auto *s : event_sinks) + s->OnActorAdded(object_id, kind, pos, vel, owner_player); +} + +void NotifyActorKilled(int object_id) { + for (auto *s : event_sinks) s->OnActorKilled(object_id); +} + +void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name) { + for (auto *s : event_sinks) + s->OnGridSpriteChanged(layer, x, y, model_name); +} + +void NotifyGridSpriteCleared(int layer, int x, int y) { + for (auto *s : event_sinks) + s->OnGridSpriteCleared(layer, x, y); +} + +void NotifySound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) { + for (auto *s : event_sinks) + s->OnSound(soundname, pos, volume, global); +} + +void NotifyInventoryChanged(int player_index, + const std::vector &model_names) { + for (auto *s : event_sinks) + s->OnInventoryChanged(player_index, model_names); +} + +void NotifyMoveCounter(int value) { + for (auto *s : event_sinks) + s->OnMoveCounter(value); +} + +void NotifyPause(bool onoff) { + for (auto *s : event_sinks) s->OnPause(onoff); +} + +void NotifyReload(int level_idx) { + for (auto *s : event_sinks) s->OnReload(level_idx); +} + +void NotifyResize(int w, int h) { + for (auto *s : event_sinks) s->OnResize(w, h); +} + /* -------------------- Client class -------------------- */ Client::Client() @@ -207,9 +281,14 @@ void Client::handle_events() { break; if (abs(e.motion.xrel) > 300 || abs(e.motion.yrel) > 300) { fprintf(stderr, "mouse event with %i, %i\n", e.motion.xrel, e.motion.yrel); - } else - server::Msg_MouseForce(options::GetDouble("MouseSpeed") * - ecl::V2(e.motion.xrel, e.motion.yrel)); + } else { + ecl::V2 f = options::GetDouble("MouseSpeed") * + ecl::V2(e.motion.xrel, e.motion.yrel); + if (netgame::IsClient()) + netgame::SendInputMouseForce(f); + else + server::Msg_MouseForce(player::CurrentPlayer(), f); + } break; case SDL_MOUSEBUTTONDOWN: case SDL_MOUSEBUTTONUP: on_mousebutton(e); break; @@ -222,9 +301,11 @@ void Client::handle_events() { case SDL_WINDOWEVENT: { update_mouse_button_state(); if (e.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { - // TODO(SDL2): is this sthe right event? The old code had - // !video::IsFullScreen() as an additional check - necessary? - show_menu(false); + // Don't auto-pause in a network game: the other peer + // keeps simulating, so popping a local menu just blocks + // input on this side and looks like a freeze. + if (!netgame::IsActive()) + show_menu(false); } else if (e.window.event == SDL_WINDOWEVENT_EXPOSED) { display::RedrawAll(video_engine->GetScreen()); } @@ -253,9 +334,11 @@ void Client::handle_events_teatime() { case SDL_WINDOWEVENT: { update_mouse_button_state(); if (e.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { - // TODO(SDL2): is this sthe right event? The old code had - // !video::IsFullScreen() as an additional check - necessary? - show_menu(false); + // Don't auto-pause in a network game: the other peer + // keeps simulating, so popping a local menu just blocks + // input on this side and looks like a freeze. + if (!netgame::IsActive()) + show_menu(false); } else if (e.window.event == SDL_WINDOWEVENT_EXPOSED) { display::RedrawAll(video_engine->GetScreen()); } @@ -271,14 +354,21 @@ void Client::handle_events_teatime() { void Client::update_mouse_button_state() { int b = SDL_GetMouseState(0, 0); - player::InhibitPickup((b & SDL_BUTTON_LMASK) || (b & SDL_BUTTON_RMASK)); + bool inhibit = (b & SDL_BUTTON_LMASK) || (b & SDL_BUTTON_RMASK); + if (netgame::IsClient()) + netgame::SendInputInhibitPickup(inhibit); + else + player::InhibitPickup(inhibit); } void Client::on_mousebutton(SDL_Event &e) { if (e.button.state == SDL_PRESSED) { if (e.button.button == SDL_BUTTON_LEFT) { // left mousebutton -> activate first item in inventory - server::Msg_ActivateItem(); + if (netgame::IsClient()) + netgame::SendInputActivateItem(); + else + server::Msg_ActivateItem(player::CurrentPlayer()); } else if (e.button.button == SDL_BUTTON_RIGHT) { // right mousebutton -> rotate inventory rotate_inventory(+1); @@ -323,7 +413,10 @@ void Client::on_mousebutton(SDL_Event &e) { void Client::rotate_inventory(int direction) { m_user_input = ""; display::GetStatusBar()->hide_text(); - player::RotateInventory(direction); + if (netgame::IsClient()) + netgame::SendInputRotateInventory(direction); + else + player::RotateInventory(direction); } /* -------------------- Console related -------------------- */ @@ -448,14 +541,25 @@ void Client::on_keydown(SDL_Event &e) { SDL_Keycode keysym = e.key.keysym.sym; Uint16 keymod = e.key.keysym.mod; + // For commands that act on a specific player (e.g. "suicide"), + // local invocations target the current player. The remote + // forwards the command and the host substitutes the connection's + // player index when dispatching. + auto send_command = [](const std::string &cmd) { + if (netgame::IsClient()) + netgame::SendInputCommand(cmd); + else + server::Msg_Command(cmd, player::CurrentPlayer()); + }; + if (keymod & KMOD_CTRL) { switch (keysym) { - case SDLK_a: server::Msg_Command("restart"); break; + case SDLK_a: send_command("restart"); break; case SDLK_F3: if (keymod & KMOD_SHIFT) { // force a reload from file lev::Proxy::releaseCache(); - server::Msg_Command("restart"); + send_command("restart"); } default: break; }; @@ -509,14 +613,29 @@ void Client::on_keydown(SDL_Event &e) { break; case SDLK_F3: if (keymod & KMOD_SHIFT) - server::Msg_Command("restart"); + send_command("restart"); else - server::Msg_Command("suicide"); + send_command("suicide"); break; - case SDLK_F4: Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); break; - case SDLK_F5: Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); break; - case SDLK_F6: Msg_JumpBack(); break; + case SDLK_F4: + if (netgame::IsClient()) + netgame::SendInputCommand("advance_strict"); + else + Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); + break; + case SDLK_F5: + if (netgame::IsClient()) + netgame::SendInputCommand("advance_unsolved"); + else + Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); + break; + case SDLK_F6: + if (netgame::IsClient()) + netgame::SendInputCommand("jumpback"); + else + Msg_JumpBack(); + break; case SDLK_F10: { video_engine->Screenshot(server::LoadedProxy->getNextScreenshotPath()); @@ -698,6 +817,12 @@ void Client::tick(double dtime) { m_timeaccu = 0; m_total_game_time = 0; sdl::FlushEvents(); + // The level transition has just finished and the screen + // looks completely different from when the cursor last + // saved its underlying pixels. Refresh that snapshot so + // the next mouse move doesn't paint stale menu pixels + // into the game view. + video_engine->RecaptureMouseBackground(); } break; } @@ -959,6 +1084,7 @@ bool NetworkStart() { void Msg_LevelLoaded(bool isRestart) { client_instance.level_loaded(isRestart); + for (auto *s : event_sinks) s->OnLevelLoaded(isRestart); } void Tick(double dtime) { @@ -971,21 +1097,43 @@ void Stop() { } void Msg_AdvanceLevel(lev::LevelAdvanceMode mode) { + for (auto *s : event_sinks) s->OnAdvanceLevel(mode); lev::Index *level_index = lev::Index::getCurrentIndex(); // log last played level lev::PersistentIndex::addCurrentToHistory(); - if (level_index->advanceLevel(mode)) { - // now we may advance - server::Msg_LoadLevel(level_index->getCurrent(), false); + bool ok = level_index->advanceLevel(mode); + // In a LAN session, keep advancing until we hit a level that the + // pack marks as network-playable; landing on a single-player-only + // level mid-session would silently switch off two-player mode. + while (ok && netgame::IsActive()) { + lev::Proxy *p = level_index->getCurrent(); + bool is_network = false; + if (p) { + try { p->loadMetadata(true); } catch (...) {} + is_network = p->hasNetworkMode(); + } + if (is_network) break; + ok = level_index->advanceLevel(mode); + } + + if (ok) { + // The remote doesn't run levels itself — the host streams the + // new world state. We just advance the index for the caption. + if (!netgame::IsClient()) + server::Msg_LoadLevel(level_index->getCurrent(), false); } else client::Msg_Command("abort"); } void Msg_JumpBack() { + for (auto *s : event_sinks) s->OnJumpBack(); // log last played level lev::PersistentIndex::addCurrentToHistory(); - server::Msg_JumpBack(); + // On the remote, the host runs the actual jumpback and streams + // the new world state; we skip the local load. + if (!netgame::IsClient()) + server::Msg_JumpBack(); } bool AbortGameP() { @@ -993,6 +1141,7 @@ bool AbortGameP() { } void Msg_Command(const std::string &cmd) { + for (auto *s : event_sinks) s->OnCommand(cmd); if (cmd == "abort") { client_instance.abort(); } else if (cmd == "level_finished") { @@ -1008,6 +1157,7 @@ void Msg_Command(const std::string &cmd) { } void Msg_PlayerPosition(unsigned iplayer, const ecl::V2 &pos) { + for (auto *s : event_sinks) s->OnPlayerPosition(iplayer, pos); if (iplayer == (unsigned)player::CurrentPlayer()) { sound::SetListenerPosition(pos); display::SetReferencePoint(pos); @@ -1015,6 +1165,7 @@ void Msg_PlayerPosition(unsigned iplayer, const ecl::V2 &pos) { } void Msg_PlaySound(const std::string &wavfile, const ecl::V2 &pos, double relative_volume) { + // Broadcast happens via the tap inside sound::EmitSoundEvent. sound::EmitSoundEvent(wavfile.c_str(), pos, relative_volume); } @@ -1023,23 +1174,30 @@ void Msg_PlaySound(const std::string &wavfile, double relative_volume) { } void Msg_Sparkle(const ecl::V2 &pos) { + for (auto *s : event_sinks) s->OnSparkle(pos); display::AddEffect(pos, "ring-anim", true); } void Msg_ShowText(const std::string &text, bool scrolling, double duration) { + for (auto *s : event_sinks) s->OnShowText(text, scrolling, duration); display::GetStatusBar()->show_text(text, scrolling, duration); } void Msg_ShowDocument(const std::string &text, bool scrolling, double duration) { + for (auto *s : event_sinks) s->OnShowDocument(text, scrolling, duration); client_instance.registerDocument(text); - Msg_ShowText(text, scrolling, duration); + // Don't call Msg_ShowText: that would re-fire OnShowText for the same + // payload. + display::GetStatusBar()->show_text(text, scrolling, duration); } void Msg_FinishedText() { + for (auto *s : event_sinks) s->OnFinishedText(); client_instance.finishedText(); } void Msg_Teatime(bool onoff) { + for (auto *s : event_sinks) s->OnTeatime(onoff); if (onoff) Msg_ShowText(_("Teatime!"), false, 0.1); // Note that client's time does not tick during teatime, @@ -1048,6 +1206,7 @@ void Msg_Teatime(bool onoff) { } void Msg_Error(const std::string &text) { + for (auto *s : event_sinks) s->OnError(text); client_instance.error(text); } diff --git a/src/client.hh b/src/client.hh index e6bd75fc9..a06f41d20 100644 --- a/src/client.hh +++ b/src/client.hh @@ -18,8 +18,13 @@ #ifndef CLIENT_HH_INCLUDED #define CLIENT_HH_INCLUDED +#include "ecl_math.hh" #include "lev/Index.hh" +#include +#include +#include + namespace enigma { namespace client { @@ -36,6 +41,108 @@ bool AbortGameP(); void Stop(); +/* -------------------- Outbound event sink -------------------- + + Every cross-cutting server→client effect (Msg_* below, plus the + Notify* hooks at engine mutation choke-points) is pushed to any + registered EventSink in addition to taking its normal local effect. + Used as a tap for an outbound network stream; default no-op so + subclasses override only what they care about. +*/ + +class EventSink { +public: + virtual ~EventSink() = default; + + virtual void OnCommand(const std::string &cmd) {} + virtual void OnAdvanceLevel(lev::LevelAdvanceMode mode) {} + virtual void OnJumpBack() {} + virtual void OnLevelLoaded(bool isRestart) {} + virtual void OnPlayerPosition(unsigned iplayer, const ecl::V2 &pos) {} + virtual void OnSparkle(const ecl::V2 &pos) {} + virtual void OnShowText(const std::string &text, bool scrolling, double duration) {} + virtual void OnShowDocument(const std::string &text, bool scrolling, double duration) {} + virtual void OnFinishedText() {} + virtual void OnTeatime(bool onoff) {} + virtual void OnError(const std::string &text) {} + + // Every sound::EmitSoundEvent on the host. global=true means the + // sound is positionless; the client should play it without spatial + // attenuation. Replaces the older OnPlaySound* hooks. + virtual void OnSound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) {} + + // The status bar's per-player inventory contents. Fired whenever + // any player's inventory changes (additions, removals, reorders). + // The host fires this for both players; each peer renders the + // side that matches its local CurrentPlayer. + virtual void OnInventoryChanged(int player_index, + const std::vector &model_names) {} + + // The status bar's stone-move counter (sokoban-style score). + virtual void OnMoveCounter(int value) {} + + // Per-tick state, keyed by the actor's Object::getId(). Same id on + // both peers because the handshake aligns Object::next_id and the + // level load is deterministic from the shared seed. + virtual void OnActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) {} + virtual void OnActorSpriteChanged(int object_id, const std::string &model_name) {} + + // Actor lifecycle: fired when an actor is added to or yielded from + // the live actor list mid-game (Drop.cc rotor swap, cannons, Lua + // spawns, etc.). The remote mirrors the call so the actor exists + // on its side too, with a matching Object id. + virtual void OnActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) {} + virtual void OnActorKilled(int object_id) {} + + // layer is GRID_FLOOR / GRID_ITEMS / GRID_STONES. + virtual void OnGridSpriteChanged(int layer, int x, int y, + const std::string &model_name) {} + virtual void OnGridSpriteCleared(int layer, int x, int y) {} + + // Server-side pause state. The host fires this when its menu / + // help screen opens or closes; the remote uses it to display a + // "paused by host" overlay. + virtual void OnPause(bool onoff) {} + + // Host has loaded a level. Used for the in-session level + // transitions (restart, advance) — the remote uses this as the + // signal to drop its current world state before applying the + // grid/actor events that follow. + virtual void OnReload(int level_idx) {} + + // Host's world was resized. Fires from World::Resize, twice per + // load (initial 20x13 from PrepareLevel, then the actual size + // from the level's createWorld). The remote calls Resize(w, h) + // locally; the empty world is then populated by subsequent + // SV_GRID_SPRITE and SV_ACTOR_ADDED events. + virtual void OnResize(int w, int h) {} +}; + +void RegisterEventSink(EventSink *sink); +void UnregisterEventSink(EventSink *sink); + +// Notify hooks for engine mutation points that don't pass through +// a Msg_* function. No local side effect; purely a tap for sinks. +void NotifyActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel); +void NotifyActorSpriteChanged(int object_id, const std::string &model_name); +void NotifyActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player); +void NotifyActorKilled(int object_id); +void NotifyGridSpriteChanged(int layer, int x, int y, const std::string &model_name); +void NotifyGridSpriteCleared(int layer, int x, int y); +void NotifySound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global); +void NotifyInventoryChanged(int player_index, + const std::vector &model_names); +void NotifyMoveCounter(int value); +void NotifyPause(bool onoff); +void NotifyReload(int level_idx); +void NotifyResize(int w, int h); + /* -------------------- Server->Client messages -------------------- */ void Msg_Command(const std::string &cmd); diff --git a/src/display.cc b/src/display.cc index 29ebfc4c7..0de727e97 100644 --- a/src/display.cc +++ b/src/display.cc @@ -134,6 +134,7 @@ void StatusBarImpl::set_counter(int new_counter) { if (m_showcounter_p && new_counter != m_counter) { m_changedp = true; m_counter = new_counter; + client::NotifyMoveCounter(new_counter); } } @@ -1941,10 +1942,12 @@ Model *display::SetModel(const GridLoc &l, Model *m) { } Model *display::SetModel(const GridLoc &l, const string &modelname) { + client::NotifyGridSpriteChanged(int(l.layer), l.pos.x, l.pos.y, modelname); return SetModel(l, MakeModel(modelname)); } void display::KillModel(const GridLoc &l) { + client::NotifyGridSpriteCleared(int(l.layer), l.pos.x, l.pos.y); delete YieldModel(l); } diff --git a/src/floors.cc b/src/floors.cc index c96e8b5cc..1ce1831b3 100644 --- a/src/floors.cc +++ b/src/floors.cc @@ -73,10 +73,10 @@ Value Floor::message(const Message &m) { } ecl::V2 Floor::process_mouseforce(Actor *a, ecl::V2 force) { - if (a->controlled_by(player::CurrentPlayer())) - return getAdhesion() * force; - else - return ecl::V2(); + // The per-actor sum in MouseForce::get_force already zeros the + // force for actors not controlled by any active-input player, + // so we just scale by the floor's adhesion here. + return getAdhesion() * force; } void Floor::setAttr(const string &key, const Value &val) { diff --git a/src/floors/SimpleFloors.cc b/src/floors/SimpleFloors.cc index 342e6b47b..ce9ad15d7 100644 --- a/src/floors/SimpleFloors.cc +++ b/src/floors/SimpleFloors.cc @@ -165,11 +165,14 @@ namespace enigma { objFlags & OBJBIT_INVISIBLE ? "_invisible" : ""); } - ecl::V2 YinyangFloor::process_mouseforce (Actor *a, ecl::V2 force) { - if (player::CurrentPlayer() == state) - return getAdhesion() * force; - else - return ecl::V2(); + ecl::V2 YinyangFloor::process_mouseforce (Actor *a, ecl::V2 /*force*/) { + // A yinyang floor passes through only the input from the player + // matching the floor's color (state 0 = yin/black, 1 = yang/white), + // applied to whichever marble is on the floor — same as the + // original single-player semantics. The `force` argument is the + // per-actor sum and would mask out cross-color cases, so we + // query the per-player force directly instead. + return getAdhesion() * GetMouseForceForPlayer(a, state); } BOOT_REGISTER_START diff --git a/src/gui/MainMenu.cc b/src/gui/MainMenu.cc index 340c3323a..33e57b62b 100644 --- a/src/gui/MainMenu.cc +++ b/src/gui/MainMenu.cc @@ -18,6 +18,7 @@ #include "gui/MainMenu.hh" #include "gui/LevelMenu.hh" +#include "gui/LevelWidget.hh" #include "gui/SearchMenu.hh" #include "gui/OptionsMenu.hh" #include "gui/InfoMenu.hh" @@ -27,6 +28,9 @@ #include "display.hh" #include "ecl_font.hh" #include "ecl_system.hh" +#include "ecl_util.hh" +#include "lev/Index.hh" +#include "lev/Proxy.hh" #include "main.hh" #include "nls.hh" #include "options.hh" @@ -47,10 +51,10 @@ namespace enigma { namespace gui { { const VMInfo *vminfo = video_engine->GetInfo(); - BuildVList b(this, Rect((vminfo->width - 150)/2,150,150,40), 5); - startgame = b.add(new StaticTextButton(N_("Start Game"), this)); + BuildVList b(this, Rect((vminfo->width - 200)/2, 200, 200, 40), 10); + m_hostgame = b.add(new StaticTextButton(N_("Host Game"), this)); m_joingame = b.add(new StaticTextButton(N_("Join Game"), this)); - m_back = b.add(new StaticTextButton(N_("Back"), this)); + m_back = b.add(new StaticTextButton(N_("Back"), this)); } NetworkMenu::~NetworkMenu () @@ -64,14 +68,17 @@ namespace enigma { namespace gui { void NetworkMenu::on_action(gui::Widget *w) { - if (w == startgame) { - netgame::Start(); - } - else if (w == m_joingame) { - netgame::Join("localhost", 12345); - } - if (w == m_back) + if (w == m_hostgame) { + HostLobbyMenu m; + m.manage(); + invalidate_all(); + } else if (w == m_joingame) { + JoinLobbyMenu m; + m.manage(); + invalidate_all(); + } else if (w == m_back) { Menu::quit(); + } } void NetworkMenu::draw_background(ecl::GC &gc) @@ -84,6 +91,361 @@ namespace enigma { namespace gui { { } + /* -------------------- Lobby helpers -------------------- */ + + namespace { + bool proxy_is_network_mode(lev::Proxy *p) { + if (!p) return false; + try { + p->loadMetadata(true); + } catch (...) { + return false; + } + return p->hasNetworkMode(); + } + } + + /* -------------------- HostLobbyMenu -------------------- */ + + HostLobbyMenu::HostLobbyMenu() + : lbl_code(new Label("", HALIGN_LEFT)), + lbl_port(new Label("", HALIGN_RIGHT)), + lbl_pack(new Label("", HALIGN_LEFT)), + lbl_level(new Label("", HALIGN_LEFT)), + lbl_status(new Label("", HALIGN_LEFT)), + lbl_failed(new Label("", HALIGN_LEFT)), + levelwidget(new LevelWidget(/*withScoreIcons=*/true, + /*withEditBorder=*/false)), + game_started(false), + armed(false), + armed_pos(0) + { + const VMInfo *vminfo = video_engine->GetInfo(); + int w = vminfo->width; + int h = vminfo->height; + int margin = 20; + int label_h = 26; + + // Top row: code on the left, port on the right. + int top_y = 50; + this->add(lbl_code, Rect(margin, top_y, (w-2*margin)/2, label_h)); + this->add(lbl_port, Rect(w/2, top_y, (w-2*margin)/2, label_h)); + + // Pack row with prev/next buttons. + int pack_y = top_y + label_h + 8; + int btn_w = 80; + int btn_h = label_h; + but_prev_pack = new StaticTextButton(N_("< Pack"), this); + but_next_pack = new StaticTextButton(N_("Pack >"), this); + int pack_label_x = margin; + int pack_label_w = w - 2*margin - 2*(btn_w + 6); + this->add(lbl_pack, Rect(pack_label_x, pack_y, pack_label_w, btn_h)); + this->add(but_prev_pack, Rect(pack_label_x + pack_label_w + 6, + pack_y, btn_w, btn_h)); + this->add(but_next_pack, Rect(pack_label_x + pack_label_w + 6 + btn_w + 6, + pack_y, btn_w, btn_h)); + + // Level grid: takes the bulk of the remaining vertical space. + int grid_y = pack_y + btn_h + 8; + int bottom_block_h = 3 * (label_h + 6) + 40 + margin; + int grid_h = std::max(120, h - grid_y - bottom_block_h); + Rect grid_area(margin, grid_y, w - 2*margin, grid_h); + levelwidget->set_listener(this); + levelwidget->realize(grid_area); + levelwidget->set_area(grid_area); + this->add(levelwidget); + + // Below the grid: selected-level label, then status + failed, + // then Start/Cancel buttons. + int info_y = grid_y + grid_h + 6; + this->add(lbl_level, Rect(margin, info_y, w - 2*margin, label_h)); + this->add(lbl_status, Rect(margin, info_y + label_h + 4, w - 2*margin, label_h)); + this->add(lbl_failed, Rect(margin, info_y + 2*(label_h + 4), w - 2*margin, label_h)); + + int sb_w = 140; + int sb_h = 36; + int sb_y = h - sb_h - margin; + but_cancel = new StaticTextButton(N_("Cancel"), this); + this->add(but_cancel, Rect((w - sb_w) / 2, sb_y, sb_w, sb_h)); + + // Open the listener. If it fails (port in use), put the error + // into the status label; user can hit Cancel. + int port = 12345; + if (!netgame::OpenHostLobby(port)) { + lbl_status->set_text(_("Could not open listening port.")); + } + + levelwidget->syncFromIndexMgr(); + update_level_label(); + update_status(); + } + + HostLobbyMenu::~HostLobbyMenu() { + if (!game_started) + netgame::CloseHostLobby(); + } + + bool HostLobbyMenu::current_level_is_network() { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind) return false; + return proxy_is_network_mode(ind->getProxy(ind->getCurrentPosition())); + } + + void HostLobbyMenu::update_level_label() { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (!ind || ind->size() == 0) { + lbl_pack->set_text(_("Level pack: (none)")); + lbl_level->set_text(_("Level: (none)")); + } else { + int pos = ind->getCurrentPosition(); + lev::Proxy *p = ind->getProxy(pos); + std::string title = p ? p->getTitle() : "?"; + lbl_pack->set_text(ecl::strf(_("Level pack: %s"), + ind->getName().c_str())); + std::string suffix = current_level_is_network() + ? _(" [network]") + : _(" [single-player — may not work]"); + lbl_level->set_text(ecl::strf(_("Level: #%d - %s"), + pos + 1, title.c_str()) + suffix); + } + } + + void HostLobbyMenu::update_status() { + lbl_code->set_text(ecl::strf(_("Access code: %s"), + netgame::LobbyCode().c_str())); + lbl_port->set_text(ecl::strf(_("Listening on 0.0.0.0:%d"), + netgame::LobbyPort())); + bool ready = netgame::LobbyHasReadyClient(); + bool pending = netgame::LobbyHasPendingClient(); + std::string armed_title; + if (armed) { + if (lev::Index *ai = lev::Index::findIndex(armed_pack)) { + if (armed_pos >= 0 && armed_pos < ai->size()) { + if (lev::Proxy *p = ai->getProxy(armed_pos)) + armed_title = ecl::strf("#%d - %s", + armed_pos + 1, p->getTitle().c_str()); + } + } + } + if (ready && armed) { + lbl_status->set_text(_("Starting...")); + } else if (ready) { + lbl_status->set_text(_("Client connected — click a level to play.")); + } else if (armed) { + lbl_status->set_text(ecl::strf( + _("Will play %s — waiting for client..."), + armed_title.c_str())); + } else if (pending) { + lbl_status->set_text(_("Client connecting — waiting for code...")); + } else if (netgame::LobbyPort() != 0) { + lbl_status->set_text(_("Waiting for client; click a level when ready.")); + } + int n = netgame::LobbyFailedAttempts(); + if (n == 0) { + lbl_failed->set_text(_("Failed attempts: 0")); + } else { + lbl_failed->set_text( + ecl::strf(_("Failed attempts: %d (last: %s)"), + n, netgame::LobbyLastFailReason().c_str())); + } + } + + bool HostLobbyMenu::on_event(const SDL_Event &e) { + return false; + } + + void HostLobbyMenu::on_action(gui::Widget *w) { + if (w == but_cancel) { + Menu::quit(); + return; + } + if (w == but_prev_pack || w == but_next_pack) { + lev::Index *cur = lev::Index::getCurrentIndex(); + if (!cur) return; + lev::Index *target = (w == but_next_pack) + ? lev::Index::nextGroupIndex() + : lev::Index::previousGroupIndex(); + if (target && target != cur) + lev::Index::setCurrentIndex(target->getName()); + levelwidget->syncFromIndexMgr(); + update_level_label(); + invalidate_all(); + return; + } + if (w == levelwidget) { + // The user clicked (or hit Enter on) a level: arm it. The + // game launches as soon as a client has authenticated, or + // immediately if one already has. Re-clicking before the + // client arrives replaces the armed level with the new one. + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind && ind->size() > 0) { + armed = true; + armed_pack = ind->getName(); + armed_pos = ind->getCurrentPosition(); + } + update_level_label(); + invalidate_all(); + return; + } + } + + void HostLobbyMenu::draw_background(ecl::GC &gc) { + set_caption(_("Enigma - Host Lobby")); + const VMInfo *vminfo = video_engine->GetInfo(); + blit(gc, vminfo->mbg_offsetx, vminfo->mbg_offsety, + enigma::GetImage("menu_bg", ".jpg")); + Font *f = enigma::GetFont("menufontsel"); + std::string title = _("Host Lobby"); + int tw = f->get_width(title.c_str()); + f->render(gc, (vminfo->width - tw) / 2, 40, title.c_str()); + } + + void HostLobbyMenu::tick(double dtime) { + if (game_started) return; + levelwidget->tick(dtime); + netgame::ServiceHostLobby(); + + // Launch the game once both ingredients are present: an armed + // level (user has clicked or pressed Enter) and an + // authenticated client. StartHostedGame runs the whole game + // synchronously and returns when it ends. + if (armed && netgame::LobbyHasReadyClient()) { + game_started = true; + netgame::StartHostedGame(armed_pack, armed_pos); + Menu::quit(); + return; + } + + static double accu = 0; + accu += 0.01; + if (accu >= 0.2) { + accu = 0; + // Cursor on the LevelWidget changes via mouse motion or + // arrow keys, neither of which routes through on_action. + // Refresh the label so the selection display stays + // in sync. + update_level_label(); + update_status(); + invalidate_all(); + } + } + + /* -------------------- JoinLobbyMenu -------------------- */ + + JoinLobbyMenu::JoinLobbyMenu() + : tf_host(new TextField( + app.state->getString("NetGameJoinHost").empty() + ? std::string("localhost") + : app.state->getString("NetGameJoinHost"))), + tf_port(new TextField("12345")), + tf_code(new TextField("")), + lbl_status(new Label("", HALIGN_LEFT)) + { + const VMInfo *vminfo = video_engine->GetInfo(); + int w = vminfo->width; + + int label_w = 90; + int field_w = 260; + int row_w = label_w + 10 + field_w; + int row_x = (w - row_w) / 2; + int y = 130; + int row_h = 36; + int row_gap = 14; + + auto add_field = [&](const char *labeltext, TextField *tf) { + Label *l = new Label(labeltext, HALIGN_RIGHT); + this->add(l, Rect(row_x, y, label_w, row_h)); + this->add(tf, Rect(row_x + label_w + 10, y, field_w, row_h)); + y += row_h + row_gap; + }; + add_field(N_("Host:"), tf_host); + add_field(N_("Port:"), tf_port); + add_field(N_("Code:"), tf_code); + + tf_port->setInvalidChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!?"); + tf_port->setMaxChars(5); + tf_code->setMaxChars(6); + tf_code->setInvalidChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!?@./:"); + + y += 6; + this->add(lbl_status, Rect(row_x, y, row_w, row_h)); + y += row_h + 16; + + int sb_w = 140; + int sb_h = 36; + int sb_gap = 20; + int sb_total = sb_w * 2 + sb_gap; + int sb_x = (w - sb_total) / 2; + but_connect = new StaticTextButton(N_("Connect"), this); + but_back = new StaticTextButton(N_("Back"), this); + this->add(but_connect, Rect(sb_x, y, sb_w, sb_h)); + this->add(but_back, Rect(sb_x + sb_w + sb_gap, y, sb_w, sb_h)); + + std::string last_err = netgame::LastJoinError(); + if (!last_err.empty()) + lbl_status->set_text(_("Last error: ") + last_err); + } + + JoinLobbyMenu::~JoinLobbyMenu() { + } + + bool JoinLobbyMenu::on_event(const SDL_Event &e) { + return false; + } + + void JoinLobbyMenu::do_connect() { + std::string host = tf_host->getText(); + std::string portstr = tf_port->getText(); + std::string code = tf_code->getText(); + int port = std::atoi(portstr.c_str()); + if (host.empty()) { + lbl_status->set_text(_("Please enter a host name.")); + invalidate_all(); + return; + } + if (code.size() != 6) { + lbl_status->set_text(_("Code must be 6 digits.")); + invalidate_all(); + return; + } + lbl_status->set_text(_("Connecting...")); + draw_all(); + refresh(); + netgame::Join(host, port, code); + std::string err = netgame::LastJoinError(); + if (err.empty()) { + // Game ran to completion; remember the host for next + // time and close this menu. + app.state->setProperty("NetGameJoinHost", host); + Menu::quit(); + } else { + lbl_status->set_text(err); + invalidate_all(); + } + } + + void JoinLobbyMenu::on_action(gui::Widget *w) { + if (w == but_connect) { + do_connect(); + } else if (w == but_back) { + Menu::quit(); + } + } + + void JoinLobbyMenu::draw_background(ecl::GC &gc) { + set_caption(_("Enigma - Join Game")); + const VMInfo *vminfo = video_engine->GetInfo(); + blit(gc, vminfo->mbg_offsetx, vminfo->mbg_offsety, + enigma::GetImage("menu_bg", ".jpg")); + Font *f = enigma::GetFont("menufontsel"); + std::string title = _("Join Game"); + int tw = f->get_width(title.c_str()); + f->render(gc, (vminfo->width - tw) / 2, 40, title.c_str()); + } + + void JoinLobbyMenu::tick(double /*dtime*/) { + } + /* -------------------- Help menu -------------------- */ static const char *credit_text[] = { @@ -450,9 +812,7 @@ namespace enigma { namespace gui { BuildVList *brp = vsmall ? &br : &b; startgame = b.add(new StaticTextButton(N_("Start Game"), this)); levelpack = b.add(new StaticTextButton(N_("Level Pack"), this)); -#ifdef ENABLE_EXPERIMENTAL m_netgame = b.add(new StaticTextButton(N_("Network Game"), this)); -#endif search = b.add(new StaticTextButton(N_("Search"), this)); options = brp->add(new StaticTextButton(N_("Options"), this)); #if 0 @@ -552,10 +912,8 @@ namespace enigma { namespace gui { MainHelpMenu m; m.manage(); - #ifdef ENABLE_EXPERIMENTAL } else if (w == m_netgame) { ShowNetworkMenu(); - #endif } else if (w == quit) { Menu::quit(); } else if (w == languagemenu) { diff --git a/src/gui/MainMenu.hh b/src/gui/MainMenu.hh index fff8719fb..8512efd08 100644 --- a/src/gui/MainMenu.hh +++ b/src/gui/MainMenu.hh @@ -22,6 +22,7 @@ #include "gui/Menu.hh" #include "gui/widgets.hh" +#include "gui/TextField.hh" #include #include @@ -88,7 +89,7 @@ namespace enigma { namespace gui { }; /* -------------------- NetworkMenu -------------------- */ - + class NetworkMenu : public gui::Menu { public: NetworkMenu (); @@ -103,12 +104,68 @@ namespace enigma { namespace gui { void tick(double dtime); // Variables. - gui::Widget *startgame; + gui::Widget *m_hostgame; gui::Widget *m_joingame; gui::Widget *m_back; }; - +/* -------------------- HostLobbyMenu -------------------- */ + + class LevelWidget; + + class HostLobbyMenu : public gui::Menu { + public: + HostLobbyMenu(); + ~HostLobbyMenu(); + private: + bool on_event(const SDL_Event &e) override; + void on_action(gui::Widget *w) override; + void draw_background(ecl::GC &gc) override; + void tick(double dtime) override; + + void update_status(); + void update_level_label(); + bool current_level_is_network(); + + gui::Label *lbl_code; + gui::Label *lbl_port; + gui::Label *lbl_pack; + gui::Label *lbl_level; + gui::Label *lbl_status; + gui::Label *lbl_failed; + gui::Widget *but_prev_pack; + gui::Widget *but_next_pack; + gui::Widget *but_cancel; + LevelWidget *levelwidget; + + bool game_started; + bool armed; // user has picked a level and wants to play + std::string armed_pack; + int armed_pos; + }; + +/* -------------------- JoinLobbyMenu -------------------- */ + + class JoinLobbyMenu : public gui::Menu { + public: + JoinLobbyMenu(); + ~JoinLobbyMenu(); + private: + bool on_event(const SDL_Event &e) override; + void on_action(gui::Widget *w) override; + void draw_background(ecl::GC &gc) override; + void tick(double dtime) override; + + void do_connect(); + + gui::TextField *tf_host; + gui::TextField *tf_port; + gui::TextField *tf_code; + gui::Label *lbl_status; + gui::Widget *but_connect; + gui::Widget *but_back; + }; + /* -------------------- Functions -------------------- */ void ShowMainMenu(); void ShowNetworkMenu(); diff --git a/src/gui/Menu.cc b/src/gui/Menu.cc index 874ff7f91..ba654ac23 100644 --- a/src/gui/Menu.cc +++ b/src/gui/Menu.cc @@ -20,6 +20,7 @@ #include "gui/Menu.hh" #include "SoundEffectManager.hh" #include "MusicManager.hh" +#include "netgame.hh" #include "video.hh" #include "options.hh" #include "main.hh" @@ -101,6 +102,10 @@ Menu::Menu() if(key_focus_widget && (key_focus_widget != active_widget)) key_focus_widget->tick(0.01); tick(0.01); sound::MusicTick(0.01); + // Keep the LAN session alive while this modal is open and + // forward the host's SV_PAUSE state to the remote. No-op + // when no session is active. + netgame::Service(); refresh(); } sound::EmitSoundEvent ("menuexit"); diff --git a/src/gui/TextField.cc b/src/gui/TextField.cc index c8706be4f..fc5921567 100644 --- a/src/gui/TextField.cc +++ b/src/gui/TextField.cc @@ -142,7 +142,7 @@ bool TextField::on_event(const SDL_Event &e) { //strcat(text, e.text.text); std::string newText(e.text.text); int totalLength = textPreCursor.length() + newText.length() + textPostCursor.length(); - if ( (maxChars >= 0 && totalLength >= maxChars) + if ( (maxChars >= 0 && totalLength > maxChars) || (newText.length() == 1 && invalidChars.find((char)(newText[0])) != std::string::npos)) { // string too long or invalid char sound::EmitSoundEvent ("menustop"); diff --git a/src/lev/Proxy.hh b/src/lev/Proxy.hh index 8fda50dce..8a43345c7 100644 --- a/src/lev/Proxy.hh +++ b/src/lev/Proxy.hh @@ -156,7 +156,7 @@ namespace enigma { namespace lev { protected: std::string title; - + Proxy(bool proxyIsLibrary, pathType thePathType, std::string theNormLevelPath, std::string levelId, std::string levelTitle, std::string levelAuthor, int levelScoreVersion, int levelRelease, bool levelHasEasymode, diff --git a/src/netgame.cc b/src/netgame.cc index 4f8df8961..6266c9402 100644 --- a/src/netgame.cc +++ b/src/netgame.cc @@ -17,17 +17,32 @@ * */ #include "errors.hh" +#include "actors.hh" #include "client.hh" +#include "display.hh" +#include "enigma.hh" #include "main.hh" #include "netgame.hh" #include "network.hh" +#include "Object.hh" #include "options.hh" +#include "player.hh" #include "server.hh" +#include "SoundEffectManager.hh" +#include "video.hh" +#include "world.hh" + +#include "lev/Index.hh" +#include "lev/Proxy.hh" #include "enet/enet.h" #include "enet_ver.hh" #include "SDL.h" +#include +#include +#include +#include #include using namespace enigma; @@ -35,85 +50,597 @@ using namespace enigma; #include "client_internal.hh" //====================================================================== -// SERVER +// Wire protocol //====================================================================== -namespace enigma { -namespace server { +namespace { + +constexpr Uint16 PROTO_VERSION = 4; +constexpr int NET_PORT = 12345; + +// Packet tags. Client -> host inputs live in the 0x10–0x7F band, host +// -> client events in 0x80+. The bands are separate so a malformed +// peer cannot confuse one for the other. +enum ProtoTag : Uint8 { + // Client -> host (inputs) + CL_HELLO = 0x01, + CL_AUTH = 0x02, + CL_MOUSE_FORCE = 0x10, + CL_ACTIVATE_ITEM = 0x11, + CL_ROTATE_INVENTORY = 0x12, + CL_COMMAND = 0x13, + CL_INHIBIT_PICKUP = 0x14, + + // Host -> client (outbound EventSink stream) + SV_AUTH_OK = 0x7D, + SV_AUTH_FAIL = 0x7E, + SV_HELLO = 0x7F, + SV_COMMAND = 0x80, + SV_ADVANCE_LEVEL = 0x81, + SV_JUMP_BACK = 0x82, + SV_LEVEL_LOADED = 0x83, + SV_PLAYER_POSITION = 0x84, + SV_SPARKLE = 0x85, + SV_SOUND = 0x86, + SV_SHOW_TEXT = 0x88, + SV_SHOW_DOCUMENT = 0x89, + SV_FINISHED_TEXT = 0x8A, + SV_TEATIME = 0x8B, + SV_ERROR = 0x8C, + SV_ACTOR_MOVED = 0x8D, + SV_ACTOR_SPRITE = 0x8E, + SV_GRID_SPRITE = 0x8F, + SV_GRID_KILL = 0x90, + SV_INVENTORY = 0x91, + SV_MOVE_COUNTER = 0x92, + SV_ACTOR_ADDED = 0x93, + SV_ACTOR_KILLED = 0x94, + SV_PAUSE = 0x95, + SV_RELOAD = 0x96, + SV_RESIZE = 0x97, +}; + +constexpr int CHANNEL_UNRELIABLE = 0; +constexpr int CHANNEL_RELIABLE = 1; + +// Commands the remote is allowed to forward to the host. Anything else +// is dropped — LAN trust model, but the door doesn't need to be open +// to cheats and arbitrary jumpto/find traffic. +const std::set kAllowedClientCommands = { + "suicide", "restart", "advance_strict", "advance_unsolved", "jumpback", +}; + +// Batches outbound EventSink calls into a reliable and an unreliable +// buffer; flush() ships both. Position-style updates that arrive every +// tick go unreliable; everything else is reliable. +class NetworkSink : public client::EventSink { +public: + explicit NetworkSink(Peer *peer) : m_peer(peer) {} + + void OnCommand(const std::string &cmd) override { + m_reliable << Uint8(SV_COMMAND) << cmd; + } + void OnAdvanceLevel(lev::LevelAdvanceMode mode) override { + m_reliable << Uint8(SV_ADVANCE_LEVEL) << Uint8(mode); + } + void OnJumpBack() override { + m_reliable << Uint8(SV_JUMP_BACK); + } + void OnLevelLoaded(bool isRestart) override { + m_reliable << Uint8(SV_LEVEL_LOADED) << Uint8(isRestart ? 1 : 0); + } + void OnPlayerPosition(unsigned iplayer, const ecl::V2 &pos) override { + m_unreliable << Uint8(SV_PLAYER_POSITION) << Uint8(iplayer) + << double(pos[0]) << double(pos[1]); + } + void OnSparkle(const ecl::V2 &pos) override { + m_reliable << Uint8(SV_SPARKLE) << double(pos[0]) << double(pos[1]); + } + void OnShowText(const std::string &text, bool scrolling, double duration) override { + m_reliable << Uint8(SV_SHOW_TEXT) << text + << Uint8(scrolling ? 1 : 0) << double(duration); + } + void OnShowDocument(const std::string &text, bool scrolling, double duration) override { + m_reliable << Uint8(SV_SHOW_DOCUMENT) << text + << Uint8(scrolling ? 1 : 0) << double(duration); + } + void OnFinishedText() override { + m_reliable << Uint8(SV_FINISHED_TEXT); + } + void OnTeatime(bool onoff) override { + m_reliable << Uint8(SV_TEATIME) << Uint8(onoff ? 1 : 0); + } + void OnError(const std::string &text) override { + m_reliable << Uint8(SV_ERROR) << text; + } + void OnSound(const std::string &soundname, const ecl::V2 &pos, + double volume, bool global) override { + m_reliable << Uint8(SV_SOUND) << soundname + << double(pos[0]) << double(pos[1]) + << double(volume) << Uint8(global ? 1 : 0); + } + void OnInventoryChanged(int player_index, + const std::vector &model_names) override { + m_reliable << Uint8(SV_INVENTORY) << Uint8(player_index) + << Uint16(model_names.size()); + for (auto const &m : model_names) + m_reliable << m; + } + void OnMoveCounter(int value) override { + m_reliable << Uint8(SV_MOVE_COUNTER) << Uint32(value); + } + void OnActorMoved(int object_id, const ecl::V2 &pos, const ecl::V2 &vel) override { + m_unreliable << Uint8(SV_ACTOR_MOVED) << Uint32(object_id) + << double(pos[0]) << double(pos[1]) + << double(vel[0]) << double(vel[1]); + } + void OnActorSpriteChanged(int object_id, const std::string &model_name) override { + m_reliable << Uint8(SV_ACTOR_SPRITE) << Uint32(object_id) << model_name; + } + void OnActorAdded(int object_id, const std::string &kind, + const ecl::V2 &pos, const ecl::V2 &vel, + int owner_player) override { + // owner: -1 → 0xFF, else 0..1. Decoded via Sint8 round-trip. + m_reliable << Uint8(SV_ACTOR_ADDED) << Uint32(object_id) << kind + << double(pos[0]) << double(pos[1]) + << double(vel[0]) << double(vel[1]) + << Uint8(Sint8(owner_player)); + } + void OnActorKilled(int object_id) override { + m_reliable << Uint8(SV_ACTOR_KILLED) << Uint32(object_id); + } + void OnGridSpriteChanged(int layer, int x, int y, + const std::string &model_name) override { + m_reliable << Uint8(SV_GRID_SPRITE) << Uint8(layer) + << Uint16(x) << Uint16(y) << model_name; + } + void OnGridSpriteCleared(int layer, int x, int y) override { + m_reliable << Uint8(SV_GRID_KILL) << Uint8(layer) + << Uint16(x) << Uint16(y); + } + void OnPause(bool onoff) override { + m_reliable << Uint8(SV_PAUSE) << Uint8(onoff ? 1 : 0); + } + void OnReload(int level_idx) override { + m_reliable << Uint8(SV_RELOAD) << Uint32(level_idx); + } + void OnResize(int w, int h) override { + m_reliable << Uint8(SV_RESIZE) << Uint16(w) << Uint16(h); + } + + void flush() { + if (m_reliable.size() > 0) { + m_peer->send_reliable(m_reliable, CHANNEL_RELIABLE); + m_reliable.clear(); + } + if (m_unreliable.size() > 0) { + m_peer->send_message(m_unreliable, CHANNEL_UNRELIABLE); + m_unreliable.clear(); + } + } -enum ServerMessageTypes { - SVMSG_NOOP, - SVMSG_LOADLEVEL, - SVMSG_MOUSEFORCE, - SVMSG_ACTIVATEITEM, +private: + Peer *m_peer; + ecl::Buffer m_reliable; + ecl::Buffer m_unreliable; }; -} // namespace server -} // namespace enigma - -void handle_client_packet(ecl::Buffer &b, int player_no) { - Uint8 code; - while (b >> code) { - switch (code) { - case server::SVMSG_NOOP: - break; // no nothing - - // not yet used -- rewrite to index/proxy usage - // case SVMSG_LOADLEVEL: { - // Uint16 levelno; - // string levelpack; - // if (b >> levelno >> levelpack) { - // printf ("SV: Loading level %d from levelpack %s\n", int(levelno), - // levelpack.c_str()); - // server::Msg_SetLevelPack (levelpack); - // server::Msg_LoadLevel (levelno); - // } - // - // break; - // } - - case server::SVMSG_MOUSEFORCE: { - printf("mouse force\n"); - float dx, dy; - if (b >> dx >> dy) { - printf("-- yei!\n"); - server::Msg_MouseForce(ecl::V2(dx, dy)); +// Look up an Actor by its host-side Object id. Returns nullptr if no +// such object exists locally or the id resolves to a non-actor. +Actor *find_actor_by_id(Uint32 id) { + return dynamic_cast(Object::getObject(int(id))); +} + +// Parse a packet that arrived on the host. `player` is the index +// assigned to that connection (0 or 1); inputs are tagged with it on +// the server side so the simulation knows whose marble to drive. +void dispatch_input_from_client(ecl::Buffer &b, int player) { + Uint8 tag; + while (b >> tag) { + switch (tag) { + case CL_MOUSE_FORCE: { + double dx, dy; + if (b >> dx >> dy) + server::Msg_MouseForce(player, ecl::V2(dx, dy)); + break; + } + case CL_ACTIVATE_ITEM: + server::Msg_ActivateItem(player); + break; + case CL_ROTATE_INVENTORY: { + Uint8 dir; + if (b >> dir) + player::RotateInventory(player, int(Sint8(dir))); + break; + } + case CL_COMMAND: { + std::string cmd; + if (b >> cmd) { + if (kAllowedClientCommands.count(cmd)) + server::Msg_Command(cmd, player); + else + enigma::Log << "netgame: rejected client command '" << cmd << "'\n"; } break; } - - case server::SVMSG_ACTIVATEITEM: { - server::Msg_ActivateItem(); + case CL_INHIBIT_PICKUP: { + Uint8 onoff; + if (b >> onoff) + player::InhibitPickup(player, onoff != 0); break; } - - default: enigma::Log << "SV: received undefined packet: " << int(code) << "\n"; + default: + enigma::Log << "netgame: unknown CL tag 0x" << std::hex << int(tag) << "\n"; + return; } } } +// Parse a packet that arrived on a remote client and apply it to the +// local state. Goes through the existing client::Msg_* and engine +// APIs; since no NetworkSink is registered on the remote, this does +// not re-broadcast. +void dispatch_event_from_server(ecl::Buffer &b); + +} // anonymous namespace + +//====================================================================== +// Client-session state and input helpers +//====================================================================== + namespace { -Uint32 last_tick_time; +bool s_in_session = false; // host or client +bool s_in_client_session = false; // client only +bool s_paused_by_host = false; // remote: host has its menu open +Peer *s_client_peer = nullptr; +ecl::Buffer s_client_out_unreliable; +ecl::Buffer s_client_out_reliable; + +// Per-connection state on the host. Set by Start() before its main +// loop and read both by the dispatcher (to tag inputs with the +// correct player index) and by Service() (to flush its sink). +struct HostState { + Peer *peer = nullptr; + NetworkSink *sink = nullptr; + int remote_player = 1; +}; +HostState s_host; + +void flush_client_outbox() { + if (s_client_out_unreliable.size() > 0 && s_client_peer) { + s_client_peer->send_message(s_client_out_unreliable, CHANNEL_UNRELIABLE); + s_client_out_unreliable.clear(); + } + if (s_client_out_reliable.size() > 0 && s_client_peer) { + s_client_peer->send_reliable(s_client_out_reliable, CHANNEL_RELIABLE); + s_client_out_reliable.clear(); + } +} + +// Apply the host's level-reload event on the remote. We do NOT run +// the level's Lua here — the host runs the simulation and streams +// the resulting world state via SV_RESIZE + SV_GRID_SPRITE + +// SV_ACTOR_ADDED events. We make sure a (default-sized) world +// exists locally so subsequent code that walks it (Glasses, +// SetCurrentPlayer, etc.) doesn't dereference a null `level`. The +// real size arrives in the host's SV_RESIZE. +void apply_reload(int level_idx) { + lev::Index *ind = lev::Index::getCurrentIndex(); + if (ind && level_idx >= 0 && level_idx < ind->size()) + ind->setCurrentPosition(level_idx); + Resize(20, 13); +} } // namespace -void server_loop(Peer *m_peer) { - printf("SV: Entered server loop\n"); - server::InitNewGame(); - ecl::Buffer buf; - buf << client::Cl_LevelLoaded(); - m_peer->send_reliable(buf, 1); +bool netgame::IsClient() { + return s_in_client_session; +} - double dtime = 0; - while (!client::AbortGameP() && m_peer->is_connected()) { - last_tick_time = SDL_GetTicks(); +bool netgame::IsActive() { + return s_in_session; +} + +void netgame::SendInputMouseForce(const ecl::V2 &f) { + if (!s_in_client_session) return; + s_client_out_unreliable << Uint8(CL_MOUSE_FORCE) << double(f[0]) << double(f[1]); +} + +void netgame::SendInputActivateItem() { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ACTIVATE_ITEM); +} + +void netgame::SendInputRotateInventory(int dir) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_ROTATE_INVENTORY) << Uint8(Sint8(dir)); +} - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) - goto done; +void netgame::SendInputCommand(const std::string &cmd) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_COMMAND) << cmd; +} + +void netgame::SendInputInhibitPickup(bool onoff) { + if (!s_in_client_session) return; + s_client_out_reliable << Uint8(CL_INHIBIT_PICKUP) << Uint8(onoff ? 1 : 0); +} + +// Service the network connection once: drain inbound packets and flush +// outbound. Called from inside modal GUI loops (game menu, help) so a +// host whose menu is open keeps the ENet connection alive and forwards +// pause state. Called by both host and remote. +void netgame::Service() { + if (!s_in_session) return; + if (s_in_client_session) { + if (s_client_peer && s_client_peer->is_connected()) { + ecl::Buffer buf; + int dummy; + while (s_client_peer->poll_message(buf, dummy)) + dispatch_event_from_server(buf); + flush_client_outbox(); + } + } else { + if (s_host.peer && s_host.peer->is_connected()) { + ecl::Buffer buf; + int dummy; + while (s_host.peer->poll_message(buf, dummy)) + dispatch_input_from_client(buf, s_host.remote_player); + if (s_host.sink) + s_host.sink->flush(); + } + } +} + +namespace { + +void dispatch_event_from_server(ecl::Buffer &b) { + Uint8 tag; + while (b >> tag) { + switch (tag) { + case SV_COMMAND: { + std::string cmd; + if (b >> cmd) client::Msg_Command(cmd); + break; + } + case SV_ADVANCE_LEVEL: { + Uint8 mode; + if (b >> mode) client::Msg_AdvanceLevel(lev::LevelAdvanceMode(mode)); + break; + } + case SV_JUMP_BACK: + client::Msg_JumpBack(); + break; + case SV_LEVEL_LOADED: { + Uint8 restart; + if (b >> restart) client::Msg_LevelLoaded(restart != 0); + break; + } + case SV_PLAYER_POSITION: { + Uint8 ip; double x, y; + if (b >> ip >> x >> y) client::Msg_PlayerPosition(ip, ecl::V2(x, y)); + break; + } + case SV_SPARKLE: { + double x, y; + if (b >> x >> y) client::Msg_Sparkle(ecl::V2(x, y)); + break; + } + case SV_SOUND: { + std::string sn; double x, y, v; Uint8 global; + if (b >> sn >> x >> y >> v >> global) + sound::EmitSoundEvent(sn, ecl::V2(x, y), v, global != 0); + break; + } + case SV_INVENTORY: { + Uint8 player_index; Uint16 count; + if (b >> player_index >> count) { + std::vector models; + models.reserve(count); + bool ok = true; + for (Uint16 i = 0; i < count && ok; ++i) { + std::string m; + if (b >> m) models.push_back(std::move(m)); + else ok = false; + } + if (ok && int(player_index) == player::CurrentPlayer()) + display::GetStatusBar()->set_inventory( + player_index == 0 ? YIN : YANG, models); + } + break; + } + case SV_MOVE_COUNTER: { + Uint32 v; + if (b >> v) + display::GetStatusBar()->set_counter(int(v)); + break; + } + case SV_SHOW_TEXT: { + std::string text; Uint8 scr; double dur; + if (b >> text >> scr >> dur) + client::Msg_ShowText(text, scr != 0, dur); + break; + } + case SV_SHOW_DOCUMENT: { + std::string text; Uint8 scr; double dur; + if (b >> text >> scr >> dur) + client::Msg_ShowDocument(text, scr != 0, dur); + break; + } + case SV_FINISHED_TEXT: + client::Msg_FinishedText(); + break; + case SV_TEATIME: { + Uint8 on; + if (b >> on) client::Msg_Teatime(on != 0); + break; } + case SV_ERROR: { + std::string text; + if (b >> text) client::Msg_Error(text); + break; + } + case SV_ACTOR_MOVED: { + Uint32 id; double px, py, vx, vy; + if (b >> id >> px >> py >> vx >> vy) { + if (Actor *a = find_actor_by_id(id)) { + ActorInfo *ai = a->get_actorinfo(); + ai->pos = ecl::V2(px, py); + ai->vel = ecl::V2(vx, vy); + a->move_screen(); + } else { + static int miss_count = 0; + if (++miss_count <= 5) + fprintf(stderr, "CL: SV_ACTOR_MOVED for unknown id %u\n", + unsigned(id)); + } + } + break; + } + case SV_ACTOR_SPRITE: { + Uint32 id; std::string model; + if (b >> id >> model) { + if (Actor *a = find_actor_by_id(id)) + a->set_model(model); + } + break; + } + case SV_ACTOR_ADDED: { + Uint32 id; std::string kind; + double px, py, vx, vy; Uint8 owner_byte; + if (b >> id >> kind >> px >> py >> vx >> vy >> owner_byte) { + int owner = int(Sint8(owner_byte)); + // Force the new Object to land on the host's id by + // pinning next_id, creating, then restoring if the + // remote was already past that point. + int saved = Object::getNextIdSnapshot(); + Object::setNextId(int(id)); + Actor *a = MakeActor(kind.c_str()); + if (a) { + if (owner >= 0) + a->setAttr("owner", Value(int(owner))); + AddActor(px, py, a); + ActorInfo *ai = a->get_actorinfo(); + ai->vel = ecl::V2(vx, vy); + } + if (saved > int(id) + 1) + Object::setNextId(saved); + } + break; + } + case SV_ACTOR_KILLED: { + Uint32 id; + if (b >> id) { + if (Actor *a = find_actor_by_id(id)) + KillActor(a); + } + break; + } + case SV_GRID_SPRITE: { + Uint8 layer; Uint16 x, y; std::string model; + if (b >> layer >> x >> y >> model) + display::SetModel(GridLoc(GridLayer(layer), GridPos(int(x), int(y))), + model); + break; + } + case SV_GRID_KILL: { + Uint8 layer; Uint16 x, y; + if (b >> layer >> x >> y) + display::KillModel(GridLoc(GridLayer(layer), GridPos(int(x), int(y)))); + break; + } + case SV_PAUSE: { + Uint8 on; + if (b >> on) { + s_paused_by_host = (on != 0); + client::Msg_ShowText( + s_paused_by_host ? "Paused by host..." : "Resumed.", + false, s_paused_by_host ? 1e9 : 1.0); + } + break; + } + case SV_RELOAD: { + Uint32 idx; + if (b >> idx) + apply_reload(int(idx)); + break; + } + case SV_RESIZE: { + Uint16 w, h; + if (b >> w >> h) + Resize(int(w), int(h)); + break; + } + default: + enigma::Log << "netgame: unknown SV tag 0x" << std::hex << int(tag) << "\n"; + return; + } + } +} + +} // anonymous namespace + +//====================================================================== +// Handshake and main loops +//====================================================================== + +namespace { + +// RAII guard: clears the session flags on scope exit so any thrown +// exception leaves the netgame state coherent. +struct SessionGuard { + bool clear_client; + SessionGuard(bool client) : clear_client(client) {} + ~SessionGuard() { + if (clear_client) { + flush_client_outbox(); + s_in_client_session = false; + s_client_peer = nullptr; + s_paused_by_host = false; + } else { + s_host = HostState{}; + } + s_in_session = false; + } +}; + +// Pair an EventSink's lifetime with the registry. Ensures a thrown +// exception inside the loop doesn't leave a freed sink pointer in +// `event_sinks`. +struct SinkGuard { + client::EventSink *sink; + explicit SinkGuard(client::EventSink *s) : sink(s) { + client::RegisterEventSink(sink); + } + ~SinkGuard() { client::UnregisterEventSink(sink); } +}; + +// Wait up to `timeout_ms` for a single packet to arrive. Uses +// enet_host_service's own timeout so we don't busy-poll. Returns +// true with the packet's bytes in `out` on success, false on +// timeout or disconnect. +bool wait_for_packet(Peer *peer, ecl::Buffer &out, int timeout_ms) { + Uint32 start = SDL_GetTicks(); + while (peer->is_connected()) { + int dummy; + if (peer->poll_message(out, dummy)) + return true; + Uint32 elapsed = SDL_GetTicks() - start; + if (elapsed >= (Uint32)timeout_ms) + return false; + SDL_Delay(5); + } + return false; +} + +// Run the host's main loop: full simulation, plus broadcast every +// EventSink hit and consume the remote's inputs each tick. +void host_main_loop(Peer *peer) { + Uint32 last_tick_time = SDL_GetTicks(); + double dtime = 0; + while (!client::AbortGameP() && peer->is_connected() && !app.bossKeyPressed) { + last_tick_time = SDL_GetTicks(); try { client::Tick(dtime); @@ -123,189 +650,576 @@ void server_loop(Peer *m_peer) { server::Msg_Panic(true); } - ecl::Buffer buf; - int player_no; - while (m_peer->poll_message(buf, player_no)) { - printf("SV: Received message from client %d\n", player_no); - handle_client_packet(buf, player_no); - } + netgame::Service(); - int sleeptime = 10 - (SDL_GetTicks() - last_tick_time); - if (sleeptime >= 3) // only sleep if relatively idle + int sleeptime = 10 - int(SDL_GetTicks() - last_tick_time); + if (sleeptime >= 3) SDL_Delay(sleeptime); - dtime = (SDL_GetTicks() - last_tick_time) / 1000.0; - if (fabs(1 - dtime / 0.01) < 0.2) { - // less than 20% deviation from desired frame time? + Uint32 now = SDL_GetTicks(); + dtime = (now - last_tick_time) / 1000.0; + if (fabs(1 - dtime / 0.01) < 0.2) dtime = 0.01; + if (dtime > 500.0) + dtime = 0.0; + } +} + +// Run the remote's main loop: no physics, just consume state from the +// host and forward local inputs. client::Tick still runs so the local +// display, sound, and follower keep up. +void remote_main_loop(Peer *peer) { + Uint32 last_tick_time = SDL_GetTicks(); + double dtime = 0; + while (!client::AbortGameP() && peer->is_connected() && !app.bossKeyPressed) { + last_tick_time = SDL_GetTicks(); + + try { + client::Tick(dtime); + } catch (XLevelRuntime &err) { + client::Msg_Error(std::string("Client Error: level runtime error:\n") + err.what()); + break; } - if (dtime > 500.0) /* Time has done something strange, perhaps - run backwards */ + netgame::Service(); + + int sleeptime = 10 - int(SDL_GetTicks() - last_tick_time); + if (sleeptime >= 3) + SDL_Delay(sleeptime); + Uint32 now = SDL_GetTicks(); + dtime = (now - last_tick_time) / 1000.0; + if (fabs(1 - dtime / 0.01) < 0.2) + dtime = 0.01; + if (dtime > 500.0) dtime = 0.0; } +} + +} // anonymous namespace + +//====================================================================== +// Host lobby (pre-game, holds the connection while the host is in the +// lobby UI, runs the access-code handshake) +//====================================================================== + +namespace { + +struct LobbyState { + ENetHost *enet_host = nullptr; + int port = 0; + std::string code; // 6 digits, generated on OpenHostLobby -done: - return; + // Pending: ENet-connected but not yet authenticated. Held until + // CL_AUTH arrives or the auth timeout expires. + ENetPeer *pending_peer = nullptr; + Uint32 pending_since_ms = 0; + + // Authenticated client, parked until the host clicks Start. Wrapped + // as a Peer so the existing game code can use it unchanged. + Peer *ready_peer = nullptr; + ENetPeer *ready_raw = nullptr; + + int failed_attempts = 0; + std::string last_fail_reason; +}; + +LobbyState s_lobby; +std::string s_last_join_error; + +std::string format_addr(const ENetAddress &a) { + return ecl::strf("%u.%u.%u.%u:%u", + unsigned( a.host & 0xff), + unsigned((a.host >> 8) & 0xff), + unsigned((a.host >> 16) & 0xff), + unsigned((a.host >> 24) & 0xff), + unsigned(a.port)); } -void netgame::Start() { +std::string generate_code() { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution d(0, 999999); + char buf[8]; + snprintf(buf, sizeof(buf), "%06d", d(gen)); + return std::string(buf); +} - // ---------- Create network host ---------- - ENetHost *network_host; - ENetAddress network_address; +// Send a one-packet message to an ENet peer that we are about to +// disconnect, then schedule the disconnect with a short timeout so the +// packet has a chance to reach the client. +void send_and_disconnect(ENetPeer *peer, const ecl::Buffer &buf) { + ENetPacket *pkt = enet_packet_create(buf.data(), buf.size(), + ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(peer, CHANNEL_RELIABLE, pkt); + enet_host_flush(s_lobby.enet_host); + enet_peer_disconnect(peer, 0); +} + +void record_failed_attempt(ENetPeer *peer, const std::string &reason) { + if (s_lobby.failed_attempts < INT_MAX) + s_lobby.failed_attempts++; + s_lobby.last_fail_reason = + ecl::strf("%s from %s", + reason.c_str(), + format_addr(peer->address).c_str()); + fprintf(stderr, "SV: rejected auth attempt (#%d): %s\n", + s_lobby.failed_attempts, + s_lobby.last_fail_reason.c_str()); +} + +void drop_pending(const std::string &reason) { + if (!s_lobby.pending_peer) return; + record_failed_attempt(s_lobby.pending_peer, reason); + ecl::Buffer fail; + fail << Uint8(SV_AUTH_FAIL) << reason; + send_and_disconnect(s_lobby.pending_peer, fail); + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; +} +} // anonymous namespace + +bool netgame::OpenHostLobby(int port) { + if (s_lobby.enet_host != nullptr) { + fprintf(stderr, "SV: lobby already open.\n"); + return false; + } + if (port <= 0) port = NET_PORT; + + ENetAddress network_address; network_address.host = ENET_HOST_ANY; - network_address.port = 12345; + network_address.port = port; + ENetHost *h = #ifdef ENET_VER_EQ_GT_13 - network_host = enet_host_create(&network_address, 1, 0, 0, 0); + enet_host_create(&network_address, 1, 2, 0, 0); #else - network_host = enet_host_create(&network_address, 1, 0, 0); + enet_host_create(&network_address, 1, 0, 0); #endif - if (network_host == NULL) { - fprintf(stderr, "SV: An error occurred while trying to create an ENet server host.\n"); - return; + if (h == nullptr) { + fprintf(stderr, "SV: failed to create ENet host on port %d.\n", port); + return false; } + s_lobby = LobbyState{}; + s_lobby.enet_host = h; + s_lobby.port = port; + s_lobby.code = generate_code(); + printf("SV: lobby open on port %d, access code %s\n", + port, s_lobby.code.c_str()); + return true; +} - // ---------- Wait for client(s) ---------- - ENetEvent event; - Peer *m_peer = 0; - printf("SV: Waiting for client...\n"); +void netgame::CloseHostLobby() { + if (s_lobby.enet_host == nullptr) return; + if (s_lobby.pending_peer) { + enet_peer_disconnect(s_lobby.pending_peer, 0); + s_lobby.pending_peer = nullptr; + } + if (s_lobby.ready_peer) { + s_lobby.ready_peer->disconnect(); + delete s_lobby.ready_peer; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; + } + // Pump one final round so disconnect packets get out. + ENetEvent ev; + Uint32 deadline = SDL_GetTicks() + 200; + while (SDL_GetTicks() < deadline && + enet_host_service(s_lobby.enet_host, &ev, 10) > 0) { + if (ev.type == ENET_EVENT_TYPE_RECEIVE) + enet_packet_destroy(ev.packet); + } + enet_host_destroy(s_lobby.enet_host); + s_lobby = LobbyState{}; +} - while (!m_peer) { - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) - return; - } +void netgame::ServiceHostLobby() { + if (s_lobby.enet_host == nullptr) return; + + // Drop pending peers that take too long to send their CL_AUTH. + if (s_lobby.pending_peer && + SDL_GetTicks() - s_lobby.pending_since_ms > 5000) { + drop_pending("auth timeout"); + } - while (enet_host_service(network_host, &event, 0) > 0) { - if (event.type == ENET_EVENT_TYPE_CONNECT) { - printf("SV: Connected to client\n"); - m_peer = new Peer_Enet(network_host, event.peer, 2); + ENetEvent ev; + while (enet_host_service(s_lobby.enet_host, &ev, 0) > 0) { + switch (ev.type) { + case ENET_EVENT_TYPE_CONNECT: + if (s_lobby.ready_peer || s_lobby.pending_peer) { + // Already have someone — reject this one immediately. + fprintf(stderr, "SV: refused extra connection from %s\n", + format_addr(ev.peer->address).c_str()); + ecl::Buffer fail; + fail << Uint8(SV_AUTH_FAIL) << std::string("lobby busy"); + send_and_disconnect(ev.peer, fail); + break; } + s_lobby.pending_peer = ev.peer; + s_lobby.pending_since_ms = SDL_GetTicks(); + printf("SV: connection from %s, awaiting code...\n", + format_addr(ev.peer->address).c_str()); + break; + + case ENET_EVENT_TYPE_RECEIVE: { + ecl::Buffer b; + b.assign(reinterpret_cast(ev.packet->data), + ev.packet->dataLength); + enet_packet_destroy(ev.packet); + + if (ev.peer == s_lobby.pending_peer) { + Uint8 tag = 0; + Uint16 proto = 0; + std::string supplied_code; + b >> tag >> proto >> supplied_code; + if (tag != CL_AUTH) { + drop_pending("malformed auth"); + break; + } + if (proto != PROTO_VERSION) { + drop_pending(ecl::strf("protocol mismatch (got %d, want %d)", + int(proto), int(PROTO_VERSION))); + break; + } + if (supplied_code != s_lobby.code) { + drop_pending("wrong code"); + break; + } + // Auth OK. + ecl::Buffer ok; + ok << Uint8(SV_AUTH_OK); + ENetPacket *pkt = enet_packet_create( + ok.data(), ok.size(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(s_lobby.pending_peer, + CHANNEL_RELIABLE, pkt); + enet_host_flush(s_lobby.enet_host); + + s_lobby.ready_raw = s_lobby.pending_peer; + s_lobby.ready_peer = new Peer_Enet(s_lobby.enet_host, + s_lobby.pending_peer, 2); + printf("SV: client authenticated from %s\n", + format_addr(s_lobby.pending_peer->address).c_str()); + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; + } + // Stray traffic from the ready peer before game-start is + // ignored. They won't have a sink to receive yet. + break; } - SDL_Delay(10); - } - server_loop(m_peer); + case ENET_EVENT_TYPE_DISCONNECT: + if (ev.peer == s_lobby.pending_peer) { + s_lobby.pending_peer = nullptr; + s_lobby.pending_since_ms = 0; + } else if (ev.peer == s_lobby.ready_raw) { + fprintf(stderr, "SV: authenticated client disconnected before game start.\n"); + delete s_lobby.ready_peer; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; + } + break; - m_peer->disconnect(); - delete m_peer; + default: + break; + } + } } +int netgame::LobbyPort() { return s_lobby.port; } +std::string netgame::LobbyCode() { return s_lobby.code; } +bool netgame::LobbyHasReadyClient() { return s_lobby.ready_peer != nullptr; } +bool netgame::LobbyHasPendingClient() { return s_lobby.pending_peer != nullptr; } +int netgame::LobbyFailedAttempts() { return s_lobby.failed_attempts; } +std::string netgame::LobbyLastFailReason() { return s_lobby.last_fail_reason; } + //====================================================================== -// CLIENT +// Game start (host) — promote the lobbied peer to a running session //====================================================================== -namespace { +void netgame::StartHostedGame(const std::string &level_pack, int level_pos) { + if (s_lobby.enet_host == nullptr || s_lobby.ready_peer == nullptr) { + fprintf(stderr, "SV: StartHostedGame called without a ready client.\n"); + return; + } -struct MovementCommand { - float time_stamp; - float force_x; - float force_y; -}; + // Transfer ownership of the ENet host and peer out of the lobby so + // CloseHostLobby() doesn't touch them after we return. + ENetHost *network_host = s_lobby.enet_host; + Peer *peer = s_lobby.ready_peer; + s_lobby.enet_host = nullptr; + s_lobby.ready_peer = nullptr; + s_lobby.ready_raw = nullptr; + int failed_during_lobby = s_lobby.failed_attempts; + s_lobby = LobbyState{}; -typedef std::list MovementList; + if (!lev::Index::setCurrentIndex(level_pack)) { + fprintf(stderr, "SV: missing level pack '%s'.\n", level_pack.c_str()); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + lev::Index *ind = lev::Index::getCurrentIndex(); + if (level_pos < 0 || level_pos >= ind->size()) { + fprintf(stderr, "SV: invalid level position %d in pack '%s'.\n", + level_pos, level_pack.c_str()); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + ind->setCurrentPosition(level_pos); + lev::Proxy *proxy = ind->getProxy(level_pos); + if (proxy == nullptr) { + fprintf(stderr, "SV: no current level selected.\n"); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + printf("SV: hosting level pack '%s' level %d (%s); %d failed auth attempts during lobby\n", + ind->getName().c_str(), level_pos + 1, + proxy->getTitle().c_str(), failed_during_lobby); -MovementList movement_list; + // The seed travels to the remote in SV_HELLO; mixing in a heap + // pointer would leak ASLR information. Use std::random_device so + // there's nothing the remote can learn about the host's address + // space. + Uint32 seed = std::random_device{}(); + const int remote_player = 1; -Peer *server_peer; + s_in_session = true; + SessionGuard guard(/*client=*/false); -} // namespace + NetworkSink sink(peer); + s_host.peer = peer; + s_host.sink = &sink; + s_host.remote_player = remote_player; -void handle_server_packet(ecl::Buffer &buf) { - if (buf.size() > 0) { - Uint8 code; - buf >> code; - switch (code) { - case client::CLMSG_LEVEL_LOADED: printf("cl_level_loaded\n"); break; + { + ecl::Buffer hello; + hello << Uint8(SV_HELLO) + << PROTO_VERSION + << ind->getName() + << Uint32(level_pos) + << seed + << Uint8(remote_player); + peer->send_reliable(hello, CHANNEL_RELIABLE); + } + ecl::Buffer reply; + if (!wait_for_packet(peer, reply, /*timeout_ms=*/10000)) { + fprintf(stderr, "SV: handshake timed out.\n"); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; + } + { + Uint8 tag = 0; + Uint16 cl_proto = 0; + reply >> tag >> cl_proto; + if (tag != CL_HELLO || cl_proto != PROTO_VERSION) { + fprintf(stderr, "SV: handshake mismatch (tag=0x%x proto=%d).\n", tag, int(cl_proto)); + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); + return; } } - ecl::Buffer obuf; - obuf << Uint8(server::SVMSG_LOADLEVEL); - obuf << Uint16(84); - obuf << std::string("Enigma"); - server_peer->send_reliable(obuf, 1); - printf("CL: sending message %u\n", (unsigned)obuf.size()); + SinkGuard sink_guard(&sink); + + server::RandomState = Sint32(seed); + server::Msg_LoadLevel(proxy, false); + + video_engine->HideMouse(); + ScopedInputGrab grab(not enigma::Nograb); + + host_main_loop(peer); + + video_engine->ShowMouse(); + + fprintf(stderr, "SV: host loop exited (connected=%d, abort=%d)\n", + int(peer->is_connected()), int(client::AbortGameP())); + + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); } -void netgame::Join(std::string hostname, int port) { - printf("CL: trying to join remote game\n"); +//====================================================================== +// Client join +//====================================================================== - // ---------- Create network host ---------- - ENetHost *m_network_host; - m_network_host = enet_host_create(NULL, 1 /* only allow 1 outgoing connection */, +std::string netgame::LastJoinError() { + return s_last_join_error; +} + +namespace { + +// Helper to bail out of Join with cleanup and a stored error reason. +struct JoinAbort { + Peer **peer; + ENetHost *host; + JoinAbort(Peer **p, ENetHost *h) : peer(p), host(h) {} + void operator()(const std::string &reason) { + s_last_join_error = reason; + fprintf(stderr, "CL: %s\n", reason.c_str()); + if (*peer) { + (*peer)->disconnect(); + delete *peer; + *peer = nullptr; + } + if (host) enet_host_destroy(host); + } +}; + +} // namespace + +void netgame::Join(std::string hostname, int port, std::string code) { + if (port <= 0) port = NET_PORT; + s_last_join_error.clear(); + printf("CL: connecting to %s:%d...\n", hostname.c_str(), port); + + ENetHost *network_host = enet_host_create(nullptr, 1, #ifdef ENET_VER_EQ_GT_13 - 2 /* 2 channels are sufficient */, + 2, #endif - 57600 / 8 /* 56K modem with 56 Kbps downstream bandwidth */, - 14400 / 8 /* 56K modem with 14 Kbps upstream bandwidth */); - - if (m_network_host == NULL) { - fprintf(stderr, "CL: An error occurred while trying to create an ENet client host.\n"); + 0, 0); + if (network_host == nullptr) { + s_last_join_error = "failed to create ENet client host"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); return; } - // ---------- Connect to server ---------- - ENetAddress sv_address; - ENetPeer *m_server; - - /* Connect to some.server.net:1234. */ - enet_address_set_host(&sv_address, hostname.c_str()); + if (enet_address_set_host(&sv_address, hostname.c_str()) != 0) { + s_last_join_error = "could not resolve host '" + hostname + "'"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); + enet_host_destroy(network_host); + return; + } sv_address.port = port; - /* Initiate the connection, allocating the two channels 0 and 1. */ - int numchannels = 2; + ENetPeer *raw_peer = #ifdef ENET_VER_EQ_GT_13 - m_server = enet_host_connect(m_network_host, &sv_address, numchannels, 57600); + enet_host_connect(network_host, &sv_address, 2, 0); #else - m_server = enet_host_connect(m_network_host, &sv_address, numchannels); + enet_host_connect(network_host, &sv_address, 2); #endif - - if (m_server == NULL) { - fprintf(stderr, "CL: No available peers for initiating an ENet connection.\n"); + if (raw_peer == nullptr) { + s_last_join_error = "no available peers for connection"; + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); + enet_host_destroy(network_host); return; } - server_peer = 0; ENetEvent event; - if (enet_host_service(m_network_host, &event, 5000) > 0 && - event.type == ENET_EVENT_TYPE_CONNECT) { - fprintf(stderr, "CL: Connection to some.server.net:12345 succeeded.\n"); - if (m_server != event.peer) - printf("CL: peers differ!?!\n"); - server_peer = new Peer_Enet(m_network_host, m_server, 0); - } else + if (enet_host_service(network_host, &event, 5000) <= 0 || + event.type != ENET_EVENT_TYPE_CONNECT) { + s_last_join_error = ecl::strf("could not connect to %s:%d", + hostname.c_str(), port); + fprintf(stderr, "CL: %s\n", s_last_join_error.c_str()); + enet_peer_reset(raw_peer); + enet_host_destroy(network_host); return; + } + Peer *peer = new Peer_Enet(network_host, raw_peer, 0); + JoinAbort abort(&peer, network_host); - while (server_peer->is_connected()) { - SDL_Event e; - while (SDL_PollEvent(&e)) { - if (e.type && e.key.keysym.sym == SDLK_ESCAPE) - goto done; - else if (e.type == SDL_MOUSEMOTION) { - ecl::Buffer buf; - float mouseforce = options::GetDouble("MouseSpeed"); - buf << Uint8(server::SVMSG_MOUSEFORCE) << float(e.motion.xrel * mouseforce) - << float(e.motion.yrel * mouseforce); - server_peer->send_reliable(buf, 1); - } - } + // Send our access code right away. The host validates it before + // anything else and will reply SV_AUTH_OK or SV_AUTH_FAIL. + { + ecl::Buffer auth; + auth << Uint8(CL_AUTH) << PROTO_VERSION << code; + peer->send_reliable(auth, CHANNEL_RELIABLE); + } - ecl::Buffer buf; - int peerno; - while (server_peer->poll_message(buf, peerno)) { - handle_server_packet(buf); + ecl::Buffer auth_reply; + if (!wait_for_packet(peer, auth_reply, /*timeout_ms=*/10000)) { + abort("auth response timed out"); + return; + } + { + Uint8 atag = 0; + auth_reply >> atag; + if (atag == SV_AUTH_FAIL) { + std::string reason; + auth_reply >> reason; + abort("rejected by host: " + reason); + return; + } + if (atag != SV_AUTH_OK) { + abort(ecl::strf("unexpected auth tag 0x%x", int(atag))); + return; } - SDL_Delay(10); } + printf("CL: code accepted, waiting for host to start the game...\n"); + + // Now wait for SV_HELLO. This may take a while — the host is + // sitting in their lobby until they click Start. Use a long timeout. + ecl::Buffer hello; + if (!wait_for_packet(peer, hello, /*timeout_ms=*/120000)) { + abort("host did not start the game in time"); + return; + } + Uint8 tag = 0; + Uint16 proto = 0; + std::string level_pack; + Uint32 level_idx = 0; + Uint32 seed = 0; + Uint8 assigned_player = 1; + hello >> tag >> proto >> level_pack >> level_idx >> seed >> assigned_player; + if (tag != SV_HELLO || proto != PROTO_VERSION) { + abort(ecl::strf("bad SV_HELLO (tag=0x%x proto=%d, expected %d)", + tag, int(proto), int(PROTO_VERSION))); + return; + } + printf("CL: host wants level pack '%s' level %d, seed=0x%x, " + "assigned player=%d\n", + level_pack.c_str(), int(level_idx) + 1, unsigned(seed), + int(assigned_player)); + + if (!lev::Index::setCurrentIndex(level_pack)) { + abort("missing level pack '" + level_pack + "'"); + return; + } + lev::Index *ind = lev::Index::getCurrentIndex(); + if (int(level_idx) >= ind->size()) { + abort(ecl::strf("level pack only has %d levels, host asked for %d", + ind->size(), int(level_idx))); + return; + } + + s_in_session = true; + s_in_client_session = true; + s_client_peer = peer; + SessionGuard guard(/*client=*/true); + + server::RandomState = Sint32(seed); + client::Stop(); + apply_reload(int(level_idx)); + player::SetCurrentPlayer(assigned_player); + + { + ecl::Buffer ack; + ack << Uint8(CL_HELLO) << PROTO_VERSION; + peer->send_reliable(ack, CHANNEL_RELIABLE); + } + + video_engine->HideMouse(); + ScopedInputGrab grab(not enigma::Nograb); + + remote_main_loop(peer); + fprintf(stderr, "CL: remote loop exited (connected=%d, abort=%d)\n", + int(peer->is_connected()), int(client::AbortGameP())); + + video_engine->ShowMouse(); + + peer->disconnect(); + delete peer; + enet_host_destroy(network_host); +} -done: - server_peer->disconnect(); - delete server_peer; - server_peer = 0; - return; +bool netgame::IsPausedByHost() { + return s_paused_by_host; } diff --git a/src/netgame.hh b/src/netgame.hh index a77404ac6..b92e3f984 100644 --- a/src/netgame.hh +++ b/src/netgame.hh @@ -19,12 +19,75 @@ #ifndef NETGAME_HH_INCLUDED #define NETGAME_HH_INCLUDED +#include "ecl_math.hh" + #include namespace netgame { -void Start(); -void Join(std::string hostname, int port); +// Open a host listener on the given port. Generates a fresh 6-digit +// access code, returned via LobbyCode(). Returns false if the listener +// could not be created (e.g. port already in use). +bool OpenHostLobby(int port); + +// Shut down the host listener. Disconnects any pending or authenticated +// peer that wasn't promoted to a game via StartHostedGame(). +void CloseHostLobby(); + +// Drive the host's listener once: accept new connections, validate +// authentication, count failed attempts. Call from the lobby menu tick. +void ServiceHostLobby(); + +// Lobby state observers. +int LobbyPort(); +std::string LobbyCode(); +bool LobbyHasReadyClient(); // an authenticated client is waiting +bool LobbyHasPendingClient(); // someone connected but hasn't authed yet +int LobbyFailedAttempts(); +std::string LobbyLastFailReason(); + +// Take the lobbied peer and run the actual game with the chosen level. +// CloseHostLobby is implicit on return. +void StartHostedGame(const std::string &level_pack, int level_pos); + +// Connect to a host as a remote client. The code is sent as part of the +// authentication step; an empty code is rejected by any host that has a +// code set. On auth failure Join() returns immediately; on success it +// blocks for the duration of the game. +void Join(std::string hostname, int port, std::string code); + +// On client side: error reason from the most recent Join() that failed +// before the game started. Empty if the last Join succeeded (or if no +// Join has been attempted). +std::string LastJoinError(); + +// True while inside a Join() session (we are a remote client of some +// host). False during Start() and during ordinary single-player play. +bool IsClient(); + +// True while either Start() or Join() is in flight. Used by UI code +// that wants to suppress single-player-only behaviour (mouse grab, +// auto-pause on focus loss, hide cursor) during LAN play. +bool IsActive(); + +// True on the remote when the host has opened its in-game menu / help +// (i.e. the simulation is paused on the host's side). +bool IsPausedByHost(); + +// Drain the inbound packet queue and flush the outbound batches once. +// Safe to call when no session is active (no-op). The two main loops +// already call this each tick; modal GUI loops (game menu, help) also +// call it periodically so a long-open menu doesn't time out the ENet +// connection and so SV_PAUSE state reaches the remote promptly. +void Service(); + +// Send-input helpers used by the local UI layer to forward the player's +// inputs to the host. No-ops outside a client session. +void SendInputMouseForce(const ecl::V2 &f); +void SendInputActivateItem(); +void SendInputRotateInventory(int dir); +void SendInputCommand(const std::string &cmd); +void SendInputInhibitPickup(bool onoff); } // namespace netgame diff --git a/src/network.cc b/src/network.cc index 953d916c2..6e90eb459 100644 --- a/src/network.cc +++ b/src/network.cc @@ -45,9 +45,10 @@ Peer_Enet::Peer_Enet (ENetHost *host, ENetPeer *peer, int playerno) } -Peer_Enet::~Peer_Enet() +Peer_Enet::~Peer_Enet() { - disconnect(0); + if (m_connected) + disconnect(0); } diff --git a/src/oxyd.cc b/src/oxyd.cc index ff8b72b65..f4a2a40fd 100644 --- a/src/oxyd.cc +++ b/src/oxyd.cc @@ -231,7 +231,7 @@ void OxydLoader::load () { // Prepare Enigma game engine Resize (level.getWidth(), level.getHeight()); - if (config.twoplayers) + if (config.twoplayers) server::TwoPlayerGame = true; display::ResizeGameArea (20, 11); if (level.getScrolling()) @@ -239,7 +239,7 @@ void OxydLoader::load () else SetFollowMode (display::FOLLOW_SCREEN); - // Populate Enigma game + // Populate Enigma game load_floor (); load_items (); load_stones (); diff --git a/src/player.cc b/src/player.cc index 90bcc226e..8a3efd4e7 100644 --- a/src/player.cc +++ b/src/player.cc @@ -21,6 +21,7 @@ #include "Inventory.hh" #include "display.hh" #include "errors.hh" +#include "netgame.hh" #include "SoundEffectManager.hh" #include "client.hh" #include "server.hh" @@ -169,6 +170,14 @@ void player::NewGame() { inv->add_item(MakeItem("it_extralife")); } + // Broadcast every player's initial inventory. The local status bar + // for the current player is refreshed by LevelLoaded below; the + // explicit notify here makes sure a LAN peer that holds the *other* + // player also receives their starting items (extralives in + // particular). + for (int i = 0; i < nplayers; ++i) + RedrawInventory(GetInventory(i)); + unassignedActors.clear(); leveldat.reset(); } @@ -182,7 +191,10 @@ void player::AddYinYang() { } void player::LevelLoaded(bool isRestart) { - if (server::TwoPlayerGame && server::SingleComputerGame) + // The yin-yang item lets one user swap between black and white on + // a single computer. In a LAN session each peer is locked to its + // colour, so the swap mechanic doesn't apply. + if (server::TwoPlayerGame && server::SingleComputerGame && !netgame::IsActive()) AddYinYang(); RedrawInventory(); } @@ -296,6 +308,14 @@ void player::Suicide() { } } +void player::Suicide(int iplayer) { + if ((unsigned)iplayer >= players.size()) + return; + for (auto &actor : players[iplayer].actors) { + SendMessage(actor, "_suicide"); + } +} + Actor *player::ReplaceActor(unsigned iplayer, Actor *old, Actor *a) { if (iplayer >= players.size()) server::RaiseError("Invalid actor number"); @@ -495,6 +515,11 @@ void player::InhibitPickup(bool flag) { players[icurrent_player].inhibit_pickup = flag; } +void player::InhibitPickup(int iplayer, bool flag) { + if ((unsigned)iplayer < players.size()) + players[iplayer].inhibit_pickup = flag; +} + /*! Return pointer to inventory if actor may pick up items, 0 otherwise. */ Inventory *player::MayPickup(Actor *a, Item *it, bool allowFlying) { @@ -539,8 +564,10 @@ bool player::PickupAsItem(Actor *a, GridObject *obj, std::string kind) { return false; } -void player::ActivateFirstItem() { - Inventory &inv = players[icurrent_player].inventory; +void player::ActivateFirstItem(int iplayer) { + if ((unsigned)iplayer >= players.size()) + return; + Inventory &inv = players[iplayer].inventory; if (inv.size() > 0) { Item *it = inv.get_item(0); @@ -548,8 +575,8 @@ void player::ActivateFirstItem() { GridPos p; bool can_drop_item = false; std::vector::iterator itr; - for (itr = players[icurrent_player].actors.begin(); - itr != players[icurrent_player].actors.end() && ac == nullptr; itr++) { + for (itr = players[iplayer].actors.begin(); + itr != players[iplayer].actors.end() && ac == nullptr; itr++) { if (!(*itr)->is_dead()) { ac = *itr; p = GridPos(ac->get_pos()); @@ -578,8 +605,14 @@ void player::ActivateFirstItem() { } void player::RotateInventory(int dir) { + RotateInventory(CurrentPlayer(), dir); +} + +void player::RotateInventory(int iplayer, int dir) { + if ((unsigned)iplayer >= players.size()) + return; sound::EmitSoundEvent("invrotate", ecl::V2()); - Inventory &inv = players[icurrent_player].inventory; + Inventory &inv = players[iplayer].inventory; if (dir == 1) inv.rotate_left(); else @@ -589,8 +622,17 @@ void player::RotateInventory(int dir) { /** Update the specified inventory on the screen, provided it is the inventory of the current player. For all other inventories, this - function does nothing. */ + function does nothing locally -- but the per-player model list is + still announced via client::NotifyInventoryChanged so a remote peer + that holds the *other* player can refresh its own status bar. */ void player::RedrawInventory(Inventory *inv) { + int owner = inv->getOwner(); + if (owner >= 0) { + std::vector modelnames; + for (size_t i = 0; i < inv->size(); ++i) + modelnames.push_back(inv->get_item(i)->get_inventory_model()); + client::NotifyInventoryChanged(owner, modelnames); + } if (inv == GetInventory(CurrentPlayer())) RedrawInventory(); } diff --git a/src/player.hh b/src/player.hh index 509146372..3bdc90103 100644 --- a/src/player.hh +++ b/src/player.hh @@ -76,6 +76,7 @@ Inventory *GetInventory(Actor *a); bool WieldedItemIs(Actor *a, const std::string &kind); void Suicide(); +void Suicide(int iplayer); void AddActor(unsigned iplayer, Actor *a); void AddUnassignedActor(Actor *a); // actors not assigned to a player @@ -86,11 +87,13 @@ Actor *GetMainActor(unsigned iplayer); bool AllActorsDead(); void InhibitPickup(bool yesno); +void InhibitPickup(int iplayer, bool yesno); void PickupItem(Actor *a, enigma::GridPos p); bool PickupAsItem(Actor *a, GridObject *obj, std::string kind); void RotateInventory(int dir = 1); -void ActivateFirstItem(); +void ActivateFirstItem(int iplayer); +void RotateInventory(int iplayer, int dir); ItemAction ActivateItem(Item *it); void Tick(double dtime); diff --git a/src/server.cc b/src/server.cc index eaceb9d0c..f47c62b35 100644 --- a/src/server.cc +++ b/src/server.cc @@ -33,6 +33,7 @@ #include "StateManager.hh" #include "world.hh" #include "MusicManager.hh" +#include "netgame.hh" #include "enet/enet.h" @@ -356,6 +357,13 @@ void Tick(double dtime) { current_state_dtime += dtime; if (current_state_dtime >= 1.0) { lev::Index *ind = lev::Index::getCurrentIndex(); + // In a network session, tell the remote a reload is + // imminent so it can drop its current world. The host's + // re-run of load_level then fires SV_RESIZE + + // SV_GRID_SPRITE + SV_ACTOR_ADDED events that rebuild + // the remote's world state. + if (netgame::IsActive() && !netgame::IsClient()) + client::NotifyReload(ind->getCurrentPosition()); load_level(ind->getCurrent(), (state == sv_restart_level)); } else { gametick(dtime); @@ -483,7 +491,7 @@ void Msg_Command_find(const string &text) { } } -void Msg_Command(const string &cmd) { +void Msg_Command(const string &cmd, int iplayer) { lev::Index *ind = lev::Index::getCurrentIndex(); lev::Proxy *curProxy = ind->getCurrent(); @@ -491,7 +499,10 @@ void Msg_Command(const string &cmd) { if (cmd == "invrotate") { player::RotateInventory(); } else if (cmd == "suicide") { - player::Suicide(); + if (iplayer >= 0) + player::Suicide(iplayer); + else + player::Suicide(); if (!AllowSuicide) Msg_RestartGame(); } else if (cmd == "restart") { @@ -499,6 +510,12 @@ void Msg_Command(const string &cmd) { Msg_RestartGame(); } else if (cmd == "abort") { client::Msg_Command(cmd); + } else if (cmd == "jumpback") { + Msg_JumpBack(); + } else if (cmd == "advance_strict") { + client::Msg_AdvanceLevel(lev::ADVANCE_STRICTLY); + } else if (cmd == "advance_unsolved") { + client::Msg_AdvanceLevel(lev::ADVANCE_UNSOLVED); } // ------------------------------ cheats @@ -581,12 +598,15 @@ void Msg_Command(const string &cmd) { } void Msg_Pause(bool onoff) { + ServerState before = state; if (onoff && state == sv_running) state = sv_paused; else if (onoff && state == sv_teatime) state = sv_paused; else if (!onoff && state == sv_paused) state = sv_running; + if (state != before) + client::NotifyPause(onoff); } void Msg_Teatime(bool onoff) { @@ -604,8 +624,8 @@ void Msg_Panic(bool onoff) { state = sv_running; } -void Msg_MouseForce(const ecl::V2 &f) { - SetMouseForce(f); +void Msg_MouseForce(int player, const ecl::V2 &f) { + SetMouseForce(player, f); } void SetCompatibility(const char *version) { @@ -656,8 +676,8 @@ int GetMoveCounter() { return move_counter; } -void Msg_ActivateItem() { - player::ActivateFirstItem(); +void Msg_ActivateItem(int iplayer) { + player::ActivateFirstItem(iplayer); } } // namespace server diff --git a/src/server.hh b/src/server.hh index 28c34cec4..ec941c14b 100644 --- a/src/server.hh +++ b/src/server.hh @@ -199,7 +199,9 @@ void Msg_StartGame(); void Msg_RestartGame(); -void Msg_Command(const std::string &command); +// `iplayer` is the index of the originating player when meaningful +// (currently only the "suicide" command uses it; -1 means "global"). +void Msg_Command(const std::string &command, int iplayer = -1); void Msg_Pause(bool onoff); @@ -207,9 +209,9 @@ void Msg_Teatime(bool onoff); void Msg_Panic(bool onoff); -void Msg_MouseForce(const ecl::V2 &f); +void Msg_MouseForce(int player, const ecl::V2 &f); -void Msg_ActivateItem(); +void Msg_ActivateItem(int iplayer); } // namespace server } // namespace enigma diff --git a/src/video.cc b/src/video.cc index 37f1a9af7..d3dbed55d 100644 --- a/src/video.cc +++ b/src/video.cc @@ -366,6 +366,7 @@ class VideoEngineImpl : public VideoEngine { void SetMouseCursor(ecl::Surface *s, int hotx, int hoty) override; void HideMouse() override; void ShowMouse() override; + void RecaptureMouseBackground() override; int Mousex() override { return cursor->get_x(); } int Mousey() override { return cursor->get_y(); } @@ -755,6 +756,10 @@ void VideoEngineImpl::ShowMouse() { cursor->redraw(); } +void VideoEngineImpl::RecaptureMouseBackground() { + cursor->recapture_background(); +} + bool VideoEngineImpl::OpenWindow(int width, int height, bool fullscreen) { Uint32 flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; if (fullscreen) diff --git a/src/video.hh b/src/video.hh index 11807dff4..857e3af72 100644 --- a/src/video.hh +++ b/src/video.hh @@ -189,6 +189,7 @@ public: virtual void SetMouseCursor(ecl::Surface *s, int hotx, int hoty) = 0; virtual void HideMouse() = 0; virtual void ShowMouse() = 0; + virtual void RecaptureMouseBackground() = 0; virtual int Mousex() = 0; virtual int Mousey() = 0; diff --git a/src/world.cc b/src/world.cc index 1d6c670d9..1ba9a9465 100644 --- a/src/world.cc +++ b/src/world.cc @@ -417,6 +417,15 @@ void World::add_actor(Actor *a, const V2 &pos) { // if game is already running, call on_creation() from here a->on_creation(pos); } + // Tell the remote about every actor (load-time and mid-game), so + // it can mirror them on its side with a matching Object id. The + // remote doesn't run the level's Lua and otherwise wouldn't know. + int owner = -1; + if (Value v = a->getAttr("owner")) + owner = (int)v; + client::NotifyActorAdded(a->getId(), a->get_traits().name, + a->m_actorinfo.pos, a->m_actorinfo.vel, + owner); if (get_id(a) == ac_pearl_white || get_id(a) == ac_pearl_black) ChangeMeditation(+1, 0, 0, 0); @@ -425,6 +434,10 @@ void World::add_actor(Actor *a, const V2 &pos) { Actor *World::yield_actor(Actor *a) { auto i = find(actorlist.begin(), actorlist.end(), a); if (i != actorlist.end()) { + // Tell the remote before erasing so its dispatcher can find + // the actor by id (the id outlives the actor briefly). + if (!preparing_level) + client::NotifyActorKilled(a->getId()); actorlist.erase(i); if (a->left == nullptr) @@ -1432,6 +1445,15 @@ void World::move_actors(double dtime) { rest_time -= dt; } + + // Re-read actorlist size here: physics callbacks (Lua, Drop.cc, + // cannons) can add/yield actors, and using the original `nactors` + // would read past the end or miss new entries. Identity is the + // actor's Object id, which is stable across yield+append. + for (Actor *a : actorlist) { + const ActorInfo &ai = *a->get_actorinfo(); + client::NotifyActorMoved(a->getId(), ai.pos, ai.vel); + } } /* This function performs one step in the numerical integration of an @@ -1663,6 +1685,10 @@ void Resize(int w, int h) { display::NewWorld(w, h); server::WorldSized = true; player::NewGame(); + // Tell the remote so it can mirror the resize and rebuild its + // (empty) world to match. Subsequent SV_GRID_SPRITE and + // SV_ACTOR_ADDED events from the host repopulate the cells. + client::NotifyResize(w, h); } int Width() { @@ -1743,10 +1769,15 @@ bool WorldInitLevel() { return true; } -void SetMouseForce(V2 f) { - level->m_mouseforce.add_force(f); +void SetMouseForce(int player, V2 f) { + level->m_mouseforce.add_force(player, f); } +V2 GetMouseForceForPlayer(Actor *a, int player) { + return level->m_mouseforce.get_force_for_player(a, player); +} + + void NameObject(Object *obj, const std::string &name) { string oldname; if (Value v = obj->getAttr("name")) { diff --git a/src/world.hh b/src/world.hh index 0d83c3a65..abf4a8b83 100644 --- a/src/world.hh +++ b/src/world.hh @@ -178,7 +178,8 @@ PositionList GetNamedPositionList(const std::string &templ, Object *reference = void AddForceField(ForceField *ff); void RemoveForceField(ForceField *ff); -void SetMouseForce(ecl::V2 f); +void SetMouseForce(int player, ecl::V2 f); +ecl::V2 GetMouseForceForPlayer(Actor *a, int player); void SetGlobalForce(ecl::V2 force); ecl::V2 GetGlobalForce(); diff --git a/src/world_internal.hh b/src/world_internal.hh index 63a0dc29e..62ed9031e 100644 --- a/src/world_internal.hh +++ b/src/world_internal.hh @@ -44,23 +44,50 @@ typedef std::vector SignalList; /*! This class implements the "force field" that accelerates objects when the mouse is moved. Only objects that have the "mouseforce" and the "controllers" attributes set are affected - by this force field. */ + by this force field. + + Each player (0 = black/yin, 1 = white/yang) has their own + independent force vector, so two simultaneous inputs (LAN + multiplayer, future split-input local 2P) can each push their + own marble. In single-player mode only one slot is non-zero + per tick, so the sum collapses to the previous behavior. */ class MouseForce { public: - void set_force(ecl::V2 f) { force = f; } - void add_force(ecl::V2 f) { force += f; } + static constexpr int kNumPlayers = 2; + + void set_force(int player, ecl::V2 f) { forces[player] = f; } + void add_force(int player, ecl::V2 f) { forces[player] += f; } - ecl::V2 get_force(Actor *a) { + /*! Sum of forces from every player that controls this actor, + scaled by the actor's adhesion. Used by ordinary floors. */ + ecl::V2 get_force(Actor *a) const { if (a->is_flying() || a->is_dead()) return ecl::V2(); - else - return force * a->get_mouseforce(); + ecl::V2 result; + for (int p = 0; p < kNumPlayers; ++p) { + if (a->controlled_by(p)) + result += forces[p]; + } + return result * a->get_mouseforce(); } - void tick(double /*dtime*/) { force = ecl::V2(); } + /*! Raw per-player force scaled by the actor's adhesion, + ignoring the actor's controllers mask. Used by YinyangFloor, + where the floor color (not the marble color) selects which + player's input is in effect. */ + ecl::V2 get_force_for_player(Actor *a, int player) const { + if (a->is_flying() || a->is_dead()) + return ecl::V2(); + return forces[player] * a->get_mouseforce(); + } + + void tick(double /*dtime*/) { + for (int p = 0; p < kNumPlayers; ++p) + forces[p] = ecl::V2(); + } private: - ecl::V2 force; + ecl::V2 forces[kNumPlayers]; }; /* -------------------- Scramble -------------------- */