Brandling Safari: LAN safari-zone creature catching on the HD-2D engine
- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass / water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon - Client: title / join / character creator / play screens, prediction + reconciliation, interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection) - Shared deterministic zone generator, movement, protocol, roster, appearance - Paper-doll character art pipeline (PixelLab base walk + per-item animation edits) - Brandling roster from the company list (logo colors, jev types), Blender safari props - Headless test bot
This commit is contained in:
1028
client/App.cpp
Normal file
1028
client/App.cpp
Normal file
File diff suppressed because it is too large
Load Diff
139
client/App.h
Normal file
139
client/App.h
Normal file
@@ -0,0 +1,139 @@
|
||||
#pragma once
|
||||
// Client application: window, renderer, screens (title, join, character
|
||||
// creator, play) and the glue between NetClient, World and the HD-2D UI.
|
||||
|
||||
#include "Common.h"
|
||||
#include "NetClient.h"
|
||||
#include "World.h"
|
||||
|
||||
#include "hd2d/Camera.h"
|
||||
#include "hd2d/DebugUI.h"
|
||||
#include "hd2d/Renderer.h"
|
||||
#include "hd2d/Screenshot.h"
|
||||
#include "hd2d/UI.h"
|
||||
#include "hd2d/Window.h"
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
struct Options {
|
||||
std::string screenshot; // capture to this PNG, then exit (hidden window)
|
||||
int frames = 120; // frames before the capture
|
||||
int width = 1600, height = 900;
|
||||
bool show = false; // visible window even when capturing
|
||||
std::string connect; // host[:port] to join right away
|
||||
bool host = false; // start a local server and join it
|
||||
uint16_t port = kDefaultPort;
|
||||
bool autoCreate = false; // skip the creator with a default look
|
||||
bool autoDialogue = false; // captures: advance dialogue automatically
|
||||
std::string screen; // "title" | "creator" capture start screen
|
||||
std::string profile; // identity file suffix (several clients on one machine)
|
||||
std::string name; // character name for --auto-create
|
||||
glm::vec2 move{0.0f}; // held movement for captures
|
||||
bool run = false;
|
||||
float yaw = 0.0f, dist = 0.0f;
|
||||
bool hasYaw = false;
|
||||
float hour = -1.0f; // title backdrop hour
|
||||
std::string weather; // title backdrop weather
|
||||
std::vector<std::string> keys; // scripted key presses: "<frame>:<key>" (space, e, f, tab)
|
||||
std::string look; // --look body,skin,hair,hat,top,bottom,shoes (creator capture)
|
||||
};
|
||||
|
||||
class App {
|
||||
public:
|
||||
explicit App(Options opt) : opt_(std::move(opt)) {}
|
||||
int run();
|
||||
|
||||
private:
|
||||
enum class Screen { Title, Join, Connecting, Creator, Play };
|
||||
struct DialogueScript {
|
||||
std::vector<std::pair<std::string, std::string>> lines; // speaker, text
|
||||
};
|
||||
|
||||
bool init();
|
||||
void shutdown();
|
||||
void frame(float dt);
|
||||
void handleNet();
|
||||
void updateCamera(float dt);
|
||||
|
||||
// screens
|
||||
void drawTitle(ImVec2 vp);
|
||||
void drawJoin(ImVec2 vp);
|
||||
void drawConnecting(ImVec2 vp);
|
||||
void drawCreator(ImVec2 vp);
|
||||
void drawPlay(ImVec2 vp, float dt);
|
||||
void drawHud(ImVec2 vp);
|
||||
void drawLabels(ImVec2 vp);
|
||||
void drawCollection(ImVec2 vp);
|
||||
void drawChat(ImVec2 vp);
|
||||
void drawPause(ImVec2 vp);
|
||||
|
||||
void playInput(float dt);
|
||||
void startDialogue(const std::string& script);
|
||||
void beginConnect(const std::string& host, uint16_t port);
|
||||
bool startLocalServer();
|
||||
void stopLocalServer();
|
||||
void returnToTitle(const std::string& message = {});
|
||||
UiTexture* creatureIcon(uint16_t species);
|
||||
void refreshPreview();
|
||||
bool pressed(int key) const; // game key (ignored while ImGui has the keyboard)
|
||||
bool scripted(const char* key) const;
|
||||
|
||||
Options opt_;
|
||||
hd2d::Window window_;
|
||||
hd2d::Renderer renderer_;
|
||||
hd2d::DebugUI debugUi_;
|
||||
hd2d::OffscreenTarget offscreen_;
|
||||
bool capture_ = false;
|
||||
hd2d::Camera cam_;
|
||||
World world_;
|
||||
NetClient net_;
|
||||
Screen screen_ = Screen::Title;
|
||||
float time_ = 0.0f;
|
||||
int frame_ = 0;
|
||||
bool showDebug_ = false;
|
||||
float uiScale_ = 1.0f;
|
||||
|
||||
// identity
|
||||
std::string token_, lastHost_ = "127.0.0.1", savedName_;
|
||||
uint16_t lastPort_ = kDefaultPort;
|
||||
void loadIdentity();
|
||||
void saveIdentity();
|
||||
|
||||
// title / join
|
||||
int titleSel_ = 0;
|
||||
std::string message_;
|
||||
net::Scanner scanner_;
|
||||
std::vector<net::ServerInfo> found_;
|
||||
float scanTimer_ = 0.0f;
|
||||
char addressBuf_[128] = "127.0.0.1";
|
||||
int serverPid_ = -1;
|
||||
float connectTimer_ = 0.0f;
|
||||
|
||||
// creator
|
||||
Appearance look_;
|
||||
char nameBuf_[24] = "";
|
||||
UiTexture preview_;
|
||||
int previewDir_ = 0;
|
||||
bool creating_ = false;
|
||||
|
||||
// play
|
||||
hd2d::ui::DialogueBox dialogue_;
|
||||
std::string dialogueScript_;
|
||||
hd2d::ui::Toasts toasts_;
|
||||
std::map<std::string, DialogueScript> scripts_;
|
||||
UiTexture henryPortrait_;
|
||||
std::map<uint16_t, std::unique_ptr<UiTexture>> icons_;
|
||||
bool collectionOpen_ = false, pauseOpen_ = false, chatOpen_ = false;
|
||||
int collectionSel_ = 0, pauseSel_ = 0;
|
||||
char chatBuf_[128] = "";
|
||||
bool chatFocus_ = false;
|
||||
float camYawTarget_ = 0.0f;
|
||||
glm::vec2 heldMove_{0.0f};
|
||||
};
|
||||
|
||||
} // namespace bs
|
||||
134
client/CharacterSprite.cpp
Normal file
134
client/CharacterSprite.cpp
Normal file
@@ -0,0 +1,134 @@
|
||||
#include "CharacterSprite.h"
|
||||
|
||||
#include "Common.h"
|
||||
|
||||
#include "hd2d/Log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
std::array<uint32_t, 4> colorRamp(uint32_t rgb) {
|
||||
const float c[3] = {float((rgb >> 16) & 255), float((rgb >> 8) & 255), float(rgb & 255)};
|
||||
struct Step { float k; float tint[3]; };
|
||||
const Step table[3] = {{0.50f, {0.96f, 0.90f, 1.06f}}, {0.73f, {0.99f, 0.95f, 1.03f}}, {1.0f, {1.0f, 1.0f, 1.0f}}};
|
||||
std::array<uint32_t, 4> out{};
|
||||
auto pack = [](float r, float g, float b) {
|
||||
auto q = [](float v) { return uint32_t(std::clamp(int(std::lround(v)), 0, 255)); };
|
||||
return (q(r) << 16) | (q(g) << 8) | q(b);
|
||||
};
|
||||
for (int i = 0; i < 3; ++i)
|
||||
out[i] = pack(c[0] * table[i].k * table[i].tint[0], c[1] * table[i].k * table[i].tint[1],
|
||||
c[2] * table[i].k * table[i].tint[2]);
|
||||
const float warm[3] = {255.0f, 250.0f, 232.0f};
|
||||
out[3] = pack(c[0] + (warm[0] - c[0]) * 0.34f, c[1] + (warm[1] - c[1]) * 0.34f, c[2] + (warm[2] - c[2]) * 0.34f);
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool loadOptional(const std::string& path, hd2d::Image& out) {
|
||||
if (!std::filesystem::exists(path)) return false;
|
||||
return hd2d::loadImage(path, out);
|
||||
}
|
||||
|
||||
void paintShade(hd2d::Image& canvas, const hd2d::Image& shade, uint32_t color) {
|
||||
if (shade.rgba.size() != canvas.rgba.size()) return;
|
||||
auto ramp = colorRamp(color);
|
||||
const size_t n = canvas.rgba.size() / 4;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const unsigned char* s = &shade.rgba[i * 4];
|
||||
if (s[3] < 128) continue;
|
||||
int lv = std::clamp((int(s[0]) + 30) / 60, 1, 4);
|
||||
uint32_t c = ramp[size_t(lv - 1)];
|
||||
unsigned char* d = &canvas.rgba[i * 4];
|
||||
d[0] = (c >> 16) & 255, d[1] = (c >> 8) & 255, d[2] = c & 255, d[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
void paintFixed(hd2d::Image& canvas, const hd2d::Image& fixed) {
|
||||
if (fixed.rgba.size() != canvas.rgba.size()) return;
|
||||
const size_t n = canvas.rgba.size() / 4;
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (fixed.rgba[i * 4 + 3] >= 128) {
|
||||
std::copy_n(&fixed.rgba[i * 4], 3, &canvas.rgba[i * 4]);
|
||||
canvas.rgba[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CharacterArt::load(const std::string& charDir, const CharacterManifest& manifest) {
|
||||
manifest_ = &manifest;
|
||||
for (const auto& [id, def] : manifest.bodies) {
|
||||
Body b;
|
||||
const std::string d = charDir + "/" + id + "/";
|
||||
if (!hd2d::loadImage(d + "body.png", b.fixed)) {
|
||||
HD2D_ERR("character art: missing %sbody.png", d.c_str());
|
||||
return false;
|
||||
}
|
||||
loadOptional(d + "skin_shade.png", b.skin.shade);
|
||||
for (const std::string& item : def.items) {
|
||||
Layer l;
|
||||
loadOptional(d + item + "_shade.png", l.shade);
|
||||
loadOptional(d + item + ".png", l.fixed);
|
||||
b.items[item] = std::move(l);
|
||||
}
|
||||
bodies_[id] = std::move(b);
|
||||
}
|
||||
return !bodies_.empty();
|
||||
}
|
||||
|
||||
hd2d::Image CharacterArt::compose(const Appearance& look) const {
|
||||
auto it = bodies_.find(look.body);
|
||||
if (it == bodies_.end()) it = bodies_.begin();
|
||||
const Body& b = it->second;
|
||||
const CharacterManifest& m = *manifest_;
|
||||
hd2d::Image canvas;
|
||||
canvas.width = b.fixed.width;
|
||||
canvas.height = b.fixed.height;
|
||||
canvas.rgba.assign(b.fixed.rgba.size(), 0);
|
||||
|
||||
uint32_t skin = m.skinTones[std::min<size_t>(look.skin, m.skinTones.size() - 1)];
|
||||
paintShade(canvas, b.skin.shade, skin);
|
||||
// The base tee and shorts are always under everything; recolored only when chosen.
|
||||
for (auto [baseItem, slot] : {std::pair{"top_tee", int(Top)}, std::pair{"bottom_shorts", int(Bottom)}}) {
|
||||
auto li = b.items.find(baseItem);
|
||||
if (li == b.items.end()) continue;
|
||||
const ItemDef* def = m.item(baseItem);
|
||||
uint32_t color = look.items[slot] == baseItem ? look.colors[slot] : (def ? def->color : 0xf0f0f0);
|
||||
paintShade(canvas, li->second.shade, color);
|
||||
}
|
||||
paintFixed(canvas, b.fixed);
|
||||
|
||||
std::array<int, kSlotCount> order{};
|
||||
for (int s = 0; s < kSlotCount; ++s) order[s] = s;
|
||||
std::sort(order.begin(), order.end(), [&](int a, int c) { return m.slotZ[a] < m.slotZ[c]; });
|
||||
for (int s : order) {
|
||||
const std::string& id = look.items[s];
|
||||
if (id.empty()) continue;
|
||||
const ItemDef* def = m.item(id);
|
||||
if (!def || def->fromBody) continue;
|
||||
auto li = b.items.find(id);
|
||||
if (li == b.items.end()) continue;
|
||||
paintShade(canvas, li->second.shade, look.colors[s]);
|
||||
paintFixed(canvas, li->second.fixed);
|
||||
}
|
||||
hd2d::bleedTransparentColors(canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
int SheetLayout::rowFor(float relYaw, bool& flip) {
|
||||
// rows: 0 south (toward camera), 1 east (screen right), 2 north (away)
|
||||
float a = wrapAngle(relYaw);
|
||||
flip = false;
|
||||
if (std::abs(a) <= 0.785f) return 2; // away from the camera
|
||||
if (std::abs(a) >= 2.356f) return 0; // toward the camera
|
||||
flip = a < 0.0f; // screen left = mirrored east
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
49
client/CharacterSprite.h
Normal file
49
client/CharacterSprite.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
// Paper-doll compositor for player sprites. Every body and item is a sheet of
|
||||
// identical frame layout (3 direction rows x [standing, walk 0..3], 64 px
|
||||
// cells) made of a recolorable shade layer plus fixed-color pixels, so any
|
||||
// look is composed on the CPU in a few milliseconds without new art.
|
||||
// Mirrors tools/character_compose.py.
|
||||
|
||||
#include "Appearance.h"
|
||||
|
||||
#include "hd2d/Texture.h"
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace bs {
|
||||
|
||||
// Shade 1 (darkest) .. 4 (highlight) of a base color; matches ramp() in tools/character_compose.py.
|
||||
std::array<uint32_t, 4> colorRamp(uint32_t rgb);
|
||||
|
||||
class CharacterArt {
|
||||
public:
|
||||
bool load(const std::string& charDir, const CharacterManifest& manifest);
|
||||
// Full sprite sheet for a look (GL orientation, same layout as the source sheets).
|
||||
hd2d::Image compose(const Appearance& look) const;
|
||||
const CharacterManifest& manifest() const { return *manifest_; }
|
||||
|
||||
private:
|
||||
struct Layer {
|
||||
hd2d::Image shade, fixed; // either may be empty
|
||||
};
|
||||
struct Body {
|
||||
hd2d::Image fixed;
|
||||
Layer skin;
|
||||
std::map<std::string, Layer> items; // includes top_tee / bottom_shorts shade layers
|
||||
};
|
||||
std::map<std::string, Body> bodies_;
|
||||
const CharacterManifest* manifest_ = nullptr;
|
||||
};
|
||||
|
||||
// Sheet layout helpers shared by players and NPCs.
|
||||
struct SheetLayout {
|
||||
int cell = 64, framesPerDir = 5, walkFrames = 4;
|
||||
// Direction row for a facing relative to the camera; `flip` mirrors east into west.
|
||||
// relYaw: facing yaw minus camera yaw (0 = walking away from the camera).
|
||||
static int rowFor(float relYaw, bool& flip);
|
||||
};
|
||||
|
||||
} // namespace bs
|
||||
48
client/Common.cpp
Normal file
48
client/Common.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#include "Common.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
void UiTexture::upload(const hd2d::Image& img) {
|
||||
if (id_ && size_ != glm::ivec2(img.width, img.height)) reset();
|
||||
if (!id_) {
|
||||
glCreateTextures(GL_TEXTURE_2D, 1, &id_);
|
||||
glTextureStorage2D(id_, 1, GL_RGBA8, img.width, img.height);
|
||||
glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glTextureParameteri(id_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTextureParameteri(id_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
size_ = {img.width, img.height};
|
||||
}
|
||||
// Upload top row first so ImGui's (0,0) = top-left.
|
||||
std::vector<unsigned char> flipped(img.rgba.size());
|
||||
size_t row = size_t(img.width) * 4;
|
||||
for (int y = 0; y < img.height; ++y)
|
||||
std::copy_n(img.rgba.data() + size_t(img.height - 1 - y) * row, row, flipped.data() + size_t(y) * row);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTextureSubImage2D(id_, 0, 0, 0, img.width, img.height, GL_RGBA, GL_UNSIGNED_BYTE, flipped.data());
|
||||
}
|
||||
|
||||
void UiTexture::reset() {
|
||||
if (id_) glDeleteTextures(1, &id_);
|
||||
id_ = 0;
|
||||
size_ = {0, 0};
|
||||
}
|
||||
|
||||
void UiTexture::cellUV(glm::ivec2 cell, int col, int row, ImVec2& uv0, ImVec2& uv1) const {
|
||||
float w = float(std::max(size_.x, 1)), h = float(std::max(size_.y, 1));
|
||||
uv0 = ImVec2(float(col * cell.x) / w, float(row * cell.y) / h);
|
||||
uv1 = ImVec2(float((col + 1) * cell.x) / w, float((row + 1) * cell.y) / h);
|
||||
}
|
||||
|
||||
float wrapAngle(float a) {
|
||||
constexpr float kTwoPi = 6.28318530718f;
|
||||
a = std::fmod(a + 3.14159265f, kTwoPi);
|
||||
if (a < 0.0f) a += kTwoPi;
|
||||
return a - 3.14159265f;
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
61
client/Common.h
Normal file
61
client/Common.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
// Small client-wide helpers: asset paths, UI textures, color conversion.
|
||||
|
||||
#include "hd2d/GL.h"
|
||||
#include "hd2d/Texture.h"
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#ifndef BS_ASSET_DIR
|
||||
#define BS_ASSET_DIR "assets"
|
||||
#endif
|
||||
#ifndef HD2D_ENGINE_ASSET_DIR
|
||||
#define HD2D_ENGINE_ASSET_DIR "../Vibe-HD-2D/assets"
|
||||
#endif
|
||||
|
||||
namespace bs {
|
||||
|
||||
inline std::string gameAsset(const std::string& rel) { return std::string(BS_ASSET_DIR) + "/" + rel; }
|
||||
inline std::string engineAsset(const std::string& rel) { return std::string(HD2D_ENGINE_ASSET_DIR) + "/" + rel; }
|
||||
|
||||
constexpr float kPixelsPerUnit = 32.0f; // world texel density shared by all pixel art
|
||||
constexpr float kCreaturePixelsPerUnit = 40.0f; // creatures render a bit smaller than trainers
|
||||
|
||||
inline ImU32 imColor(uint32_t rgb, int alpha = 255) {
|
||||
return IM_COL32((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha);
|
||||
}
|
||||
inline glm::vec3 linearColor(uint32_t rgb) {
|
||||
auto f = [](uint32_t c) { float v = float(c & 255) / 255.0f; return v * v * (0.8f + 0.2f * v); };
|
||||
return {f(rgb >> 16), f(rgb >> 8), f(rgb)};
|
||||
}
|
||||
|
||||
// Plain RGBA8 (not sRGB), nearest-filtered texture for ImGui images: pixel art
|
||||
// previews and icons stay crisp and show their authored colors.
|
||||
class UiTexture {
|
||||
public:
|
||||
UiTexture() = default;
|
||||
UiTexture(const UiTexture&) = delete;
|
||||
UiTexture& operator=(const UiTexture&) = delete;
|
||||
~UiTexture() { reset(); }
|
||||
// `img` uses the engine convention (row 0 = bottom); ImGui uv (0,0) is the top-left.
|
||||
void upload(const hd2d::Image& img);
|
||||
void reset();
|
||||
ImTextureID id() const { return ImTextureID(intptr_t(id_)); }
|
||||
bool valid() const { return id_ != 0; }
|
||||
glm::ivec2 size() const { return size_; }
|
||||
// ImGui uv rect of a grid cell (top-left origin, rows counted from the top).
|
||||
void cellUV(glm::ivec2 cell, int col, int row, ImVec2& uv0, ImVec2& uv1) const;
|
||||
|
||||
private:
|
||||
GLuint id_ = 0;
|
||||
glm::ivec2 size_{0};
|
||||
};
|
||||
|
||||
float wrapAngle(float a); // to (-pi, pi]
|
||||
|
||||
} // namespace bs
|
||||
170
client/NetClient.cpp
Normal file
170
client/NetClient.cpp
Normal file
@@ -0,0 +1,170 @@
|
||||
#include "NetClient.h"
|
||||
|
||||
#include "hd2d/Log.h"
|
||||
|
||||
namespace bs {
|
||||
|
||||
bool NetClient::connect(const std::string& host, uint16_t port, const std::string& token) {
|
||||
disconnect();
|
||||
net::initialize();
|
||||
token_ = token;
|
||||
error_.clear();
|
||||
hasProfile_ = false;
|
||||
players.clear();
|
||||
snapshots.clear();
|
||||
events.clear();
|
||||
dialogues.clear();
|
||||
toasts.clear();
|
||||
chat.clear();
|
||||
welcome_ = {};
|
||||
profile_ = {};
|
||||
host_ = std::make_unique<net::Host>();
|
||||
if (!host_->connect(host, port)) {
|
||||
state_ = State::Failed;
|
||||
error_ = "Could not resolve " + host;
|
||||
return false;
|
||||
}
|
||||
peer_ = 1; // first (only) peer of a client host
|
||||
state_ = State::Connecting;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetClient::disconnect() {
|
||||
if (state_ != State::Idle && state_ != State::Failed && peer_) {
|
||||
host_->disconnect(peer_);
|
||||
host_->flush();
|
||||
}
|
||||
state_ = State::Idle;
|
||||
peer_ = 0;
|
||||
}
|
||||
|
||||
uint32_t NetClient::pingMs() const { return peer_ && host_ ? host_->roundTripMs(peer_) : 0; }
|
||||
|
||||
void NetClient::send(net::Channel ch, const Writer& w) {
|
||||
if (peer_ && host_) host_->send(peer_, ch, w.data());
|
||||
}
|
||||
|
||||
void NetClient::update() {
|
||||
if (!host_ || !host_->valid()) return;
|
||||
net::Event ev;
|
||||
while (host_ && host_->poll(ev, 0)) {
|
||||
switch (ev.type) {
|
||||
case net::Event::Connect: {
|
||||
peer_ = ev.peer;
|
||||
Writer w(Msg::Hello);
|
||||
HelloMsg{kProtocolVersion, token_}.write(w);
|
||||
send(net::Reliable, w);
|
||||
break;
|
||||
}
|
||||
case net::Event::Disconnect:
|
||||
if (state_ != State::Failed)
|
||||
error_ = state_ == State::Connecting ? "Could not reach the server" : "Disconnected from the server";
|
||||
state_ = State::Failed;
|
||||
peer_ = 0;
|
||||
break;
|
||||
case net::Event::Receive: handle(ev.data); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (host_) host_->flush();
|
||||
}
|
||||
|
||||
void NetClient::handle(const std::vector<uint8_t>& data) {
|
||||
if (data.empty()) return;
|
||||
Reader r(data.data() + 1, data.size() - 1);
|
||||
switch (Msg(data[0])) {
|
||||
case Msg::Welcome:
|
||||
welcome_.read(r);
|
||||
if (!r.ok()) return;
|
||||
state_ = welcome_.hasCharacter ? State::InGame : State::NeedCharacter;
|
||||
break;
|
||||
case Msg::Reject:
|
||||
error_ = r.str();
|
||||
state_ = State::Failed;
|
||||
break;
|
||||
case Msg::Profile: {
|
||||
ProfileMsg p;
|
||||
p.read(r);
|
||||
if (!r.ok()) return;
|
||||
profile_ = std::move(p);
|
||||
hasProfile_ = true;
|
||||
profileChanged = true;
|
||||
if (state_ == State::NeedCharacter) state_ = State::InGame;
|
||||
break;
|
||||
}
|
||||
case Msg::PlayerInfo: {
|
||||
PlayerInfoMsg p;
|
||||
p.read(r);
|
||||
if (r.ok()) players[p.entityId] = std::move(p);
|
||||
break;
|
||||
}
|
||||
case Msg::PlayerLeft: {
|
||||
uint32_t id = r.u32();
|
||||
players.erase(id);
|
||||
playersLeft.push_back(id);
|
||||
break;
|
||||
}
|
||||
case Msg::Snapshot: {
|
||||
SnapshotMsg s;
|
||||
s.read(r);
|
||||
if (!r.ok()) return;
|
||||
snapshots.push_back(std::move(s));
|
||||
while (snapshots.size() > 8) snapshots.pop_front();
|
||||
break;
|
||||
}
|
||||
case Msg::Event: {
|
||||
GameEvent e;
|
||||
e.read(r);
|
||||
if (r.ok()) events.push_back(e);
|
||||
break;
|
||||
}
|
||||
case Msg::Dialogue: dialogues.push_back(r.str()); break;
|
||||
case Msg::ChatLine: {
|
||||
ChatEntry c;
|
||||
c.entity = r.u32();
|
||||
c.name = r.str();
|
||||
c.text = r.str();
|
||||
if (r.ok()) chat.push_back(std::move(c));
|
||||
if (chat.size() > 40) chat.erase(chat.begin());
|
||||
break;
|
||||
}
|
||||
case Msg::Toast: toasts.push_back(r.str()); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void NetClient::sendCreate(const std::string& name, const Appearance& look) {
|
||||
Writer w(Msg::CreateCharacter);
|
||||
CreateCharacterMsg{name, look}.write(w);
|
||||
send(net::Reliable, w);
|
||||
}
|
||||
|
||||
void NetClient::sendInput(const InputCmd& cmd) {
|
||||
recentInputs_.push_back(cmd);
|
||||
if (recentInputs_.size() > 4) recentInputs_.erase(recentInputs_.begin());
|
||||
Writer w(Msg::Input);
|
||||
InputMsg m;
|
||||
m.cmds = recentInputs_;
|
||||
m.write(w);
|
||||
send(net::Unreliable, w);
|
||||
}
|
||||
|
||||
void NetClient::sendAction(ActionKind kind, uint32_t target, float x, float z) {
|
||||
Writer w(Msg::Action);
|
||||
ActionMsg{kind, target, x, z}.write(w);
|
||||
send(net::Reliable, w);
|
||||
}
|
||||
|
||||
void NetClient::sendChat(const std::string& text) {
|
||||
Writer w(Msg::Chat);
|
||||
w.str(text);
|
||||
send(net::Reliable, w);
|
||||
}
|
||||
|
||||
void NetClient::sendDialogueDone(const std::string& script) {
|
||||
Writer w(Msg::DialogueDone);
|
||||
w.str(script);
|
||||
send(net::Reliable, w);
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
67
client/NetClient.h
Normal file
67
client/NetClient.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
// Client side of the protocol: connection state machine and message queues.
|
||||
// The World consumes snapshots/events; screens consume profile/dialogue/chat.
|
||||
|
||||
#include "Net.h"
|
||||
#include "Protocol.h"
|
||||
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
struct ChatEntry {
|
||||
uint32_t entity = 0;
|
||||
std::string name, text;
|
||||
float age = 0.0f;
|
||||
};
|
||||
|
||||
class NetClient {
|
||||
public:
|
||||
enum class State { Idle, Connecting, NeedCharacter, InGame, Failed };
|
||||
|
||||
bool connect(const std::string& host, uint16_t port, const std::string& token);
|
||||
void disconnect();
|
||||
void update(); // poll the network, fill the queues
|
||||
|
||||
void sendCreate(const std::string& name, const Appearance& look);
|
||||
void sendInput(const InputCmd& cmd);
|
||||
void sendAction(ActionKind kind, uint32_t target = 0, float x = 0.0f, float z = 0.0f);
|
||||
void sendChat(const std::string& text);
|
||||
void sendDialogueDone(const std::string& script);
|
||||
|
||||
State state() const { return state_; }
|
||||
const std::string& error() const { return error_; }
|
||||
const WelcomeMsg& welcome() const { return welcome_; }
|
||||
const ProfileMsg& profile() const { return profile_; }
|
||||
bool hasProfile() const { return hasProfile_; }
|
||||
uint32_t localEntity() const { return profile_.entityId ? profile_.entityId : welcome_.entityId; }
|
||||
uint32_t pingMs() const;
|
||||
|
||||
std::map<uint32_t, PlayerInfoMsg> players; // every player entity's name + look
|
||||
std::deque<SnapshotMsg> snapshots; // newest last; the World pops them
|
||||
std::vector<GameEvent> events;
|
||||
std::vector<std::string> dialogues;
|
||||
std::vector<std::string> toasts;
|
||||
std::vector<ChatEntry> chat;
|
||||
std::vector<uint32_t> playersLeft;
|
||||
bool profileChanged = false;
|
||||
|
||||
private:
|
||||
void handle(const std::vector<uint8_t>& data);
|
||||
void send(net::Channel ch, const Writer& w);
|
||||
|
||||
std::unique_ptr<net::Host> host_;
|
||||
uint32_t peer_ = 0;
|
||||
State state_ = State::Idle;
|
||||
std::string error_, token_;
|
||||
WelcomeMsg welcome_;
|
||||
ProfileMsg profile_;
|
||||
bool hasProfile_ = false;
|
||||
std::vector<InputCmd> recentInputs_;
|
||||
};
|
||||
|
||||
} // namespace bs
|
||||
929
client/World.cpp
Normal file
929
client/World.cpp
Normal file
@@ -0,0 +1,929 @@
|
||||
#include "World.h"
|
||||
|
||||
#include "hd2d/Log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace bs {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float kPi = 3.14159265f;
|
||||
constexpr glm::ivec2 kCell{64, 64};
|
||||
|
||||
// Terrain texture array layers (Vibe-HD-2D/tools/assets.json sources).
|
||||
const char* kLayers[] = {
|
||||
"ground_grass_0", "ground_grass_1", "ground_grass_2", "ground_grass_3", // 0-3 plain grass
|
||||
"ground_grass_4", "ground_grass_5", "ground_grass_8", // 4-6 flowers / clover
|
||||
"ground_grass_12", "ground_grass_13", // 7-8 dark meadow grass
|
||||
"ground_extra_1", // 9 earth path
|
||||
"ground_extra_4", "ground_extra_5", // 10-11 earth cliff
|
||||
"ground_misc_4", "ground_misc_5", // 12-13 mossy stone cliff
|
||||
"ground_extra_8", "ground_extra_9", // 14-15 sand
|
||||
"ground_misc_12", "ground_misc_13", // 16-17 cobblestone
|
||||
};
|
||||
|
||||
float easeOutBack(float t) {
|
||||
t = std::clamp(t, 0.0f, 1.0f);
|
||||
const float c1 = 1.70158f, c3 = c1 + 1.0f;
|
||||
return 1.0f + c3 * std::pow(t - 1.0f, 3.0f) + c1 * std::pow(t - 1.0f, 2.0f);
|
||||
}
|
||||
|
||||
float lerpAngle(float a, float b, float t) { return a + wrapAngle(b - a) * t; }
|
||||
|
||||
// --- tiny procedural pixel art for effects (16x16 cells, GL orientation: row 0 = bottom)
|
||||
hd2d::Image blank(int w, int h) {
|
||||
hd2d::Image img;
|
||||
img.width = w;
|
||||
img.height = h;
|
||||
img.rgba.assign(size_t(w) * h * 4, 0);
|
||||
return img;
|
||||
}
|
||||
void put(hd2d::Image& img, int x, int yTop, uint32_t rgb) {
|
||||
int y = img.height - 1 - yTop;
|
||||
if (x < 0 || y < 0 || x >= img.width || y >= img.height) return;
|
||||
unsigned char* p = &img.rgba[(size_t(y) * img.width + x) * 4];
|
||||
p[0] = (rgb >> 16) & 255, p[1] = (rgb >> 8) & 255, p[2] = rgb & 255, p[3] = 255;
|
||||
}
|
||||
hd2d::Image capsuleImage() { // safari capsule: olive top, cream bottom, dark band and button
|
||||
hd2d::Image img = blank(16, 16);
|
||||
for (int y = 0; y < 12; ++y)
|
||||
for (int x = 0; x < 12; ++x) {
|
||||
float dx = float(x) - 5.5f, dy = float(y) - 5.5f;
|
||||
float d = std::sqrt(dx * dx + dy * dy);
|
||||
if (d > 5.9f) continue;
|
||||
uint32_t c;
|
||||
if (d > 4.9f) c = 0x1c1a14;
|
||||
else if (std::abs(dy) < 0.8f) c = 0x2a2620;
|
||||
else if (dy < 0) c = (dx < -1.5f && dy < -2.0f) ? 0xb6c46a : 0x6f8a3a;
|
||||
else c = dy > 3.0f ? 0xb9ae8e : 0xe8dfc4;
|
||||
if (d < 1.6f) c = d < 0.9f ? 0xf4f0e0 : 0x2a2620;
|
||||
put(img, x + 2, y + 4, c);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
hd2d::Image bobberImage() {
|
||||
hd2d::Image img = blank(16, 16);
|
||||
for (int y = 0; y < 6; ++y)
|
||||
for (int x = 0; x < 6; ++x) {
|
||||
float dx = float(x) - 2.5f, dy = float(y) - 2.5f;
|
||||
if (dx * dx + dy * dy > 7.5f) continue;
|
||||
put(img, x + 5, y + 8, dy < 0 ? 0xe0402c : 0xf2efe6);
|
||||
}
|
||||
put(img, 7, 7, 0x302820);
|
||||
put(img, 8, 7, 0x302820);
|
||||
return img;
|
||||
}
|
||||
hd2d::Image dotImage() {
|
||||
hd2d::Image img = blank(16, 16);
|
||||
for (int y = 13; y <= 15; ++y)
|
||||
for (int x = 7; x <= 8; ++x) put(img, x, y, y == 15 ? 0xb8b4aa : 0xf4f2ea);
|
||||
return img;
|
||||
}
|
||||
|
||||
struct ModelFlags {
|
||||
float groundAO;
|
||||
bool occluderFade;
|
||||
bool castShadows;
|
||||
};
|
||||
ModelFlags flagsFor(const std::string& name) {
|
||||
if (name.rfind("oak", 0) == 0 || name.rfind("pine", 0) == 0) return {1.0f, true, true};
|
||||
if (name.rfind("bush", 0) == 0) return {0.8f, false, true};
|
||||
if (name.rfind("rock", 0) == 0) return {0.7f, false, true};
|
||||
if (name == "tent" || name == "gate") return {0.9f, true, true};
|
||||
if (name == "dock" || name == "dock_end") return {0.0f, false, true};
|
||||
return {0.5f, false, true};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ------------------------------------------------------------------ setup
|
||||
|
||||
bool World::init() {
|
||||
if (assetsLoaded_) return true;
|
||||
if (!scenery_.load(engineAsset("models/scenery.glb"))) return false;
|
||||
if (!safari_.load(gameAsset("models/safari.glb"))) return false;
|
||||
std::string err;
|
||||
if (!manifest_.load(gameAsset("characters/manifest.json"), &err)) {
|
||||
HD2D_ERR("%s", err.c_str());
|
||||
return false;
|
||||
}
|
||||
if (!charArt_.load(gameAsset("characters"), manifest_)) return false;
|
||||
if (!roster_.load(gameAsset("creatures/roster.json"), &err)) HD2D_ERR("roster: %s", err.c_str());
|
||||
|
||||
auto npc = manifest_.npcs.find("henry");
|
||||
if (npc != manifest_.npcs.end() && hd2d::loadImage(gameAsset("characters/" + npc->second.sheet), henryImage_))
|
||||
henry_.texture.create(henryImage_);
|
||||
henry_.emissive = 0.05f;
|
||||
|
||||
const std::string tex = engineAsset("textures/");
|
||||
if (!tallGrass_.loadTexture(tex + "tall_grass.png") || !tufts_.loadTexture(tex + "grass_tuft.png") ||
|
||||
!flowers_.loadTexture(tex + "flowers.png"))
|
||||
return false;
|
||||
tufts_.inReflections = false;
|
||||
flowers_.inReflections = false;
|
||||
|
||||
if (!creatureAtlas_.init(kCell, 16, 16)) return false;
|
||||
creatures_.sharedTexture = &creatureAtlas_.texture();
|
||||
creatures_.emissive = 0.05f;
|
||||
|
||||
fxAtlas_.init({16, 16}, 4, 4);
|
||||
fxCapsule_ = fxAtlas_.add(capsuleImage());
|
||||
fxBobber_ = fxAtlas_.add(bobberImage());
|
||||
fxDot_ = fxAtlas_.add(dotImage());
|
||||
fx_.sharedTexture = &fxAtlas_.texture();
|
||||
fx_.castShadows = false;
|
||||
fx_.emissive = 0.15f;
|
||||
assetsLoaded_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool World::buildZone(const std::string& id, uint32_t seed) {
|
||||
if (built_ && zone_.id() == id && zone_.seed() == seed) return true;
|
||||
if (!zone_.generate(id, seed)) {
|
||||
HD2D_ERR("unknown zone '%s'", id.c_str());
|
||||
return false;
|
||||
}
|
||||
buildTerrain();
|
||||
buildProps();
|
||||
buildDecor();
|
||||
|
||||
water_.build(zone_.waterMin, zone_.waterMax, Zone::kWaterLevel);
|
||||
water_.buildShoreField(terrain_);
|
||||
|
||||
particles_ = std::make_unique<hd2d::ParticleSystem>();
|
||||
float W = float(zone_.width()), D = float(zone_.depth());
|
||||
particles_->addField(hd2d::ParticleSystem::Kind::Firefly, {4, 0.2f, 4}, {W - 4, 1.8f, D - 4}, 80);
|
||||
particles_->addField(hd2d::ParticleSystem::Kind::Mote, {4, 0.0f, 4}, {W - 4, 7.0f, D - 4}, 900);
|
||||
|
||||
std::vector<hd2d::ModelBatch*> occluders;
|
||||
for (auto& [name, b] : models_)
|
||||
if (b->groundOcclusion > 0.0f) occluders.push_back(b.get());
|
||||
groundAO_.build({0.0f, 0.0f}, {W, D}, occluders);
|
||||
|
||||
scene = hd2d::Scene();
|
||||
scene.terrain = &terrain_;
|
||||
scene.water = &water_;
|
||||
scene.particles = particles_.get();
|
||||
scene.groundOcclusion = &groundAO_;
|
||||
for (auto& [name, b] : models_) scene.models.push_back(b.get());
|
||||
scene.interactorRadius = 1.0f;
|
||||
scene.blobShadowRadius = 0.55f;
|
||||
weather = hd2d::Weather();
|
||||
weather.nightBrightness = 0.42f; // keep night rain readable around the trainers
|
||||
hourShown_ = -1.0f;
|
||||
built_ = true;
|
||||
reset();
|
||||
HD2D_LOG("zone %s/%u: %zu props, %zu tall grass sprites", id.c_str(), seed, zone_.props.size(),
|
||||
tallGrass_.instances.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void World::buildTerrain() {
|
||||
hd2d::Terrain& t = terrain_;
|
||||
if (t.textures.layers() == 0) {
|
||||
std::vector<std::string> layers;
|
||||
for (const char* l : kLayers) layers.push_back(engineAsset(std::string("textures/") + l + ".png"));
|
||||
t.textures.load(layers);
|
||||
}
|
||||
t.init(zone_.width(), zone_.depth(), Zone::kLevelHeight);
|
||||
t.baseLevel = -8;
|
||||
// topFirst, topCount, accentFirst, accentCount, accentChance, sideFirst, sideCount, lipLayer
|
||||
t.materials[MatGrass] = {0, 4, 4, 3, 0.10f, 10, 2, 0};
|
||||
t.materials[MatMeadow] = {7, 2, 0, 4, 0.15f, 10, 2, 7};
|
||||
t.materials[MatPath] = {9, 1, 0, 0, 0.0f, 10, 2, -1};
|
||||
t.materials[MatSand] = {14, 2, 0, 0, 0.0f, 14, 2, -1};
|
||||
t.materials[MatStone] = {16, 2, 0, 0, 0.0f, 12, 2, -1};
|
||||
t.materials[MatRidge] = {0, 4, 7, 2, 0.2f, 12, 2, 0};
|
||||
t.materials[MatPlaza] = {16, 2, 9, 1, 0.08f, 12, 2, -1};
|
||||
for (int z = 0; z < zone_.depth(); ++z)
|
||||
for (int x = 0; x < zone_.width(); ++x) {
|
||||
t.setLevel(x, z, zone_.level(x, z));
|
||||
t.setMaterial(x, z, zone_.material(x, z));
|
||||
}
|
||||
t.build();
|
||||
}
|
||||
|
||||
void World::buildProps() {
|
||||
models_.clear();
|
||||
lampLights_.clear();
|
||||
for (const ZoneProp& p : zone_.props) {
|
||||
auto it = models_.find(p.model);
|
||||
if (it == models_.end()) {
|
||||
// engine scenery (trees, bushes, rocks) vs game props (tools/blender_safari.py)
|
||||
bool engineModel = p.model.rfind("oak", 0) == 0 || p.model.rfind("pine", 0) == 0 ||
|
||||
p.model.rfind("bush", 0) == 0 || p.model.rfind("rock", 0) == 0;
|
||||
const hd2d::Model* m = engineModel ? scenery_.get(p.model) : safari_.get(p.model);
|
||||
if (!m) {
|
||||
HD2D_ERR("zone prop model '%s' not found", p.model.c_str());
|
||||
continue;
|
||||
}
|
||||
auto b = std::make_unique<hd2d::ModelBatch>(m);
|
||||
ModelFlags f = flagsFor(p.model);
|
||||
b->groundOcclusion = f.groundAO;
|
||||
b->occluderFade = f.occluderFade;
|
||||
b->castShadows = f.castShadows;
|
||||
it = models_.emplace(p.model, std::move(b)).first;
|
||||
}
|
||||
hd2d::ModelInstance inst{p.pos, p.yaw, p.scale, p.tint};
|
||||
it->second->instances.push_back(inst);
|
||||
for (const auto& [sock, local] : it->second->model->sockets)
|
||||
if (sock.rfind("light", 0) == 0) lampLights_.push_back({inst.toWorld(local), 6.5f, glm::vec3(3.4f, 1.9f, 0.75f)});
|
||||
}
|
||||
for (auto& [name, b] : models_) b->upload();
|
||||
}
|
||||
|
||||
void World::buildDecor() {
|
||||
Rng rng(zone_.seed() * 7919u + 17u);
|
||||
tallGrass_.instances.clear();
|
||||
tufts_.instances.clear();
|
||||
flowers_.instances.clear();
|
||||
const Zone& z = zone_;
|
||||
for (int tz = 0; tz < z.depth(); ++tz)
|
||||
for (int tx = 0; tx < z.width(); ++tx) {
|
||||
if (z.tallGrassTile(tx, tz)) { // dense overlapping rows of swaying clumps
|
||||
for (int row = 0; row < 2; ++row)
|
||||
for (int col = 0; col < 2; ++col) {
|
||||
glm::vec2 p(float(tx) + 0.25f + 0.5f * float(col) + rng.range(-0.08f, 0.08f),
|
||||
float(tz) + 0.3f + 0.5f * float(row) + rng.range(-0.05f, 0.05f));
|
||||
float s = rng.range(1.0f, 1.2f);
|
||||
tallGrass_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.03f, p.y}, 1.0f,
|
||||
glm::vec2(1.1f) * s, rng.chance(0.5f) ? 1.0f : 0.0f,
|
||||
rng.range(0.86f, 1.08f)});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
uint8_t m = z.material(tx, tz);
|
||||
if (z.level(tx, tz) < 0 || (m != MatGrass && m != MatRidge)) continue;
|
||||
float cluster = 0.5f + 0.5f * std::sin(float(tx) * 0.35f + 1.7f) * std::sin(float(tz) * 0.29f + 0.4f);
|
||||
int n = int(rng.uniform() * (0.3f + 2.6f * cluster * cluster));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
glm::vec2 p(float(tx) + rng.uniform(), float(tz) + rng.uniform());
|
||||
if (z.onDock(p)) continue;
|
||||
float s = rng.range(0.6f, 1.05f);
|
||||
tufts_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.02f, p.y}, 1.0f,
|
||||
tufts_.sizeForDensity(kPixelsPerUnit) * s, rng.chance(0.5f) ? 1.0f : 0.0f,
|
||||
rng.range(0.62f, 0.8f)});
|
||||
}
|
||||
if (rng.chance(0.07f)) {
|
||||
glm::vec2 p(float(tx) + rng.uniform(), float(tz) + rng.uniform());
|
||||
flowers_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.02f, p.y}, 0.8f,
|
||||
flowers_.sizeForDensity(kPixelsPerUnit) * rng.range(0.7f, 1.0f),
|
||||
rng.chance(0.5f) ? 1.0f : 0.0f, 1.0f});
|
||||
}
|
||||
}
|
||||
for (hd2d::BillboardBatch* b : {&tallGrass_, &tufts_, &flowers_}) b->upload();
|
||||
}
|
||||
|
||||
void World::reset() {
|
||||
visuals_.clear();
|
||||
playerArt_.clear();
|
||||
capsules_.clear();
|
||||
bobbers_.clear();
|
||||
pending_.clear();
|
||||
localSpawned_ = false;
|
||||
haveTick_ = false;
|
||||
correction_ = glm::vec2(0.0f);
|
||||
target_ = 0;
|
||||
biteTimer_ = 0.0f;
|
||||
localId_ = 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ assets on demand
|
||||
|
||||
int World::creatureSlot(uint16_t species) {
|
||||
auto it = speciesSlot_.find(species);
|
||||
if (it != speciesSlot_.end()) {
|
||||
slotUsed_[it->second] = time_;
|
||||
return it->second;
|
||||
}
|
||||
const Species* sp = roster_.get(species);
|
||||
hd2d::Image img;
|
||||
if (!sp || !hd2d::loadImage(gameAsset("creatures/" + sp->sprite), img)) return -1;
|
||||
int slot = creatureAtlas_.add(img);
|
||||
if (slot < 0) { // full: evict the least recently drawn species
|
||||
int victim = -1;
|
||||
float oldest = 1e30f;
|
||||
for (auto& [s, t] : slotUsed_)
|
||||
if (t < oldest) oldest = t, victim = s;
|
||||
for (auto i = speciesSlot_.begin(); i != speciesSlot_.end(); ++i)
|
||||
if (i->second == victim) {
|
||||
speciesSlot_.erase(i);
|
||||
break;
|
||||
}
|
||||
creatureAtlas_.set(victim, img);
|
||||
slot = victim;
|
||||
}
|
||||
speciesSlot_[species] = slot;
|
||||
slotUsed_[slot] = time_;
|
||||
return slot;
|
||||
}
|
||||
|
||||
hd2d::BillboardBatch* World::playerBatch(uint32_t entity, const NetClient& net) {
|
||||
auto info = net.players.find(entity);
|
||||
if (info == net.players.end()) return nullptr;
|
||||
PlayerArt& art = playerArt_[entity];
|
||||
const Appearance& look = info->second.look;
|
||||
bool same = art.batch && art.look.body == look.body && art.look.skin == look.skin && art.look.items == look.items &&
|
||||
art.look.colors == look.colors;
|
||||
if (!same) {
|
||||
art.look = look;
|
||||
art.batch = std::make_unique<hd2d::BillboardBatch>();
|
||||
art.batch->texture.create(charArt_.compose(look));
|
||||
art.batch->emissive = 0.06f;
|
||||
art.batch->instances.resize(1);
|
||||
}
|
||||
return art.batch.get();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ networking
|
||||
|
||||
void World::applySnapshots(NetClient& net) {
|
||||
localId_ = net.localEntity();
|
||||
while (!net.snapshots.empty()) {
|
||||
SnapshotMsg s = std::move(net.snapshots.front());
|
||||
net.snapshots.pop_front();
|
||||
float tick = float(s.tick);
|
||||
if (!haveTick_ || std::abs(serverTick_ - tick) > 15.0f) serverTick_ = tick, haveTick_ = true;
|
||||
else serverTick_ += (tick - serverTick_) * 0.08f;
|
||||
hour_ = s.hour;
|
||||
weatherState_ = s.weather;
|
||||
for (const EntityState& es : s.entities) {
|
||||
if (es.id == localId_ && es.kind == EntityKind::Player) {
|
||||
localServerAnim_ = es.anim;
|
||||
glm::vec2 server(es.pos.x, es.pos.z);
|
||||
if (!localSpawned_) {
|
||||
predicted_.pos = server;
|
||||
predicted_.yaw = es.yaw;
|
||||
prevPredicted_ = predicted_;
|
||||
pending_.clear();
|
||||
localSpawned_ = true;
|
||||
localRender_ = es.pos;
|
||||
continue;
|
||||
}
|
||||
while (!pending_.empty() && pending_.front().seq <= s.ackInput) pending_.pop_front();
|
||||
MoveState replay = predicted_;
|
||||
replay.pos = server;
|
||||
for (const InputCmd& c : pending_) stepMovement(zone_, replay, c, kTickDt);
|
||||
glm::vec2 err = predicted_.pos - replay.pos;
|
||||
if (glm::length(err) > 2.5f) {
|
||||
predicted_ = replay;
|
||||
prevPredicted_ = replay;
|
||||
correction_ = glm::vec2(0.0f);
|
||||
} else if (glm::length(err) > 1e-4f) {
|
||||
correction_ += err;
|
||||
prevPredicted_.pos -= err;
|
||||
predicted_.pos = replay.pos;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Visual& v = visuals_[es.id];
|
||||
if (v.id == 0) {
|
||||
v.id = es.id;
|
||||
v.born = time_;
|
||||
v.pos = es.pos;
|
||||
v.lastPos = es.pos;
|
||||
v.yaw = es.yaw;
|
||||
v.flip = v.flipTarget = 0.0f;
|
||||
}
|
||||
v.kind = es.kind;
|
||||
v.species = es.species;
|
||||
v.owner = es.owner;
|
||||
v.lastSeen = time_;
|
||||
v.history.push_back({tick, es});
|
||||
while (v.history.size() > 24) v.history.pop_front();
|
||||
}
|
||||
}
|
||||
// entities that stopped appearing in snapshots are gone
|
||||
for (auto it = visuals_.begin(); it != visuals_.end();)
|
||||
it = time_ - it->second.lastSeen > 0.6f ? visuals_.erase(it) : std::next(it);
|
||||
for (uint32_t id : net.playersLeft) playerArt_.erase(id);
|
||||
net.playersLeft.clear();
|
||||
}
|
||||
|
||||
void World::handleEvent(const GameEvent& e, NetClient& net) {
|
||||
const bool mine = e.a == net.localEntity();
|
||||
switch (e.kind) {
|
||||
case EventKind::Emerge:
|
||||
if (e.i == 1) particles_->burst(e.p + glm::vec3(0, 0.1f, 0), 28, {0.75f, 0.88f, 1.0f}, 2.6f, 0.06f, 0.9f);
|
||||
else {
|
||||
particles_->burst(e.p + glm::vec3(0, 0.45f, 0), 26, {0.22f, 0.46f, 0.12f}, 2.4f, 0.07f, 0.9f);
|
||||
particles_->burst(e.p + glm::vec3(0, 0.3f, 0), 10, {0.45f, 0.62f, 0.2f}, 1.4f, 0.05f, 0.7f);
|
||||
}
|
||||
if (auto it = visuals_.find(e.a); it != visuals_.end()) it->second.born = time_;
|
||||
break;
|
||||
case EventKind::Rustle:
|
||||
particles_->burst(e.p + glm::vec3(0, 0.4f, 0), 9, {0.25f, 0.5f, 0.14f}, 1.3f, 0.06f, 0.6f);
|
||||
break;
|
||||
case EventKind::Splash:
|
||||
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 22, {0.8f, 0.9f, 1.0f}, 2.2f, 0.05f,
|
||||
0.8f);
|
||||
break;
|
||||
case EventKind::Throw: {
|
||||
Capsule c;
|
||||
c.thrower = e.a;
|
||||
c.target = e.b;
|
||||
c.from = e.p;
|
||||
c.to = e.q;
|
||||
c.start = time_;
|
||||
c.flight = std::max(e.f, 0.15f);
|
||||
if (mine) c.from = localRender_ + glm::vec3(0, 1.0f, 0);
|
||||
capsules_.push_back(c);
|
||||
break;
|
||||
}
|
||||
case EventKind::CaptureResult:
|
||||
for (Capsule& c : capsules_)
|
||||
if (c.target == e.b && c.result < 0) {
|
||||
c.result = e.i ? 1 : 0;
|
||||
c.resultAt = time_;
|
||||
glm::vec3 at = c.to + glm::vec3(0, 0.25f, 0);
|
||||
if (e.i) {
|
||||
particles_->burst(at, 40, {1.6f, 1.3f, 0.5f}, 2.2f, 0.05f, 1.2f);
|
||||
} else {
|
||||
particles_->burst(at, 26, {1.4f, 1.4f, 1.4f}, 2.8f, 0.05f, 0.7f);
|
||||
if (auto it = visuals_.find(e.b); it != visuals_.end()) {
|
||||
it->second.hide = 0.0f;
|
||||
it->second.flash = 1.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EventKind::Fled:
|
||||
particles_->burst(e.p + glm::vec3(0, 0.2f, 0), 18, {0.55f, 0.48f, 0.38f}, 1.8f, 0.07f, 0.8f);
|
||||
break;
|
||||
case EventKind::Cast:
|
||||
bobbers_.erase(std::remove_if(bobbers_.begin(), bobbers_.end(), [&](const Bobber& b) { return b.player == e.a; }),
|
||||
bobbers_.end());
|
||||
bobbers_.push_back({e.a, e.p});
|
||||
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 8, {0.8f, 0.9f, 1.0f}, 1.0f, 0.04f,
|
||||
0.5f);
|
||||
break;
|
||||
case EventKind::Bite:
|
||||
for (Bobber& b : bobbers_)
|
||||
if (b.player == e.a) b.bite = time_;
|
||||
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 14, {0.8f, 0.9f, 1.0f}, 1.6f, 0.05f,
|
||||
0.6f);
|
||||
if (mine) biteTimer_ = 1.3f;
|
||||
break;
|
||||
case EventKind::Hooked:
|
||||
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 36, {0.8f, 0.92f, 1.0f}, 3.4f, 0.06f,
|
||||
1.0f);
|
||||
[[fallthrough]];
|
||||
case EventKind::LineLost:
|
||||
bobbers_.erase(std::remove_if(bobbers_.begin(), bobbers_.end(), [&](const Bobber& b) { return b.player == e.a; }),
|
||||
bobbers_.end());
|
||||
if (mine) biteTimer_ = 0.0f;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void World::predict(float dt, const FrameInput& in, NetClient& net, const hd2d::Camera& cam) {
|
||||
glm::vec3 right = cam.right();
|
||||
glm::vec2 r2(right.x, right.z), f2(right.z, -right.x);
|
||||
glm::vec2 dir = r2 * in.move.x + f2 * in.move.y;
|
||||
if (glm::length(dir) > 1.0f) dir = glm::normalize(dir);
|
||||
|
||||
tickAccum_ += dt;
|
||||
int steps = 0;
|
||||
while (tickAccum_ >= kTickDt && steps < 5) {
|
||||
tickAccum_ -= kTickDt;
|
||||
++steps;
|
||||
prevPredicted_ = predicted_;
|
||||
InputCmd cmd = makeInput(++inputSeq_, dir, in.run);
|
||||
if (localSpawned_) {
|
||||
stepMovement(zone_, predicted_, cmd, kTickDt);
|
||||
pending_.push_back(cmd);
|
||||
while (pending_.size() > 90) pending_.pop_front();
|
||||
}
|
||||
net.sendInput(cmd);
|
||||
}
|
||||
if (steps == 5) tickAccum_ = 0.0f;
|
||||
float alpha = std::clamp(tickAccum_ / kTickDt, 0.0f, 1.0f);
|
||||
correction_ *= std::exp(-dt * 12.0f);
|
||||
glm::vec2 p = glm::mix(prevPredicted_.pos, predicted_.pos, alpha) + correction_;
|
||||
float ground = groundAt(p);
|
||||
localRender_.x = p.x;
|
||||
localRender_.z = p.y;
|
||||
localRender_.y += (ground - localRender_.y) * std::min(1.0f, dt * 14.0f);
|
||||
if (std::abs(localRender_.y - ground) > 2.0f) localRender_.y = ground;
|
||||
if (predicted_.moving) localPhase_ += dt * (predicted_.running ? 11.0f : 7.5f);
|
||||
else localPhase_ = 0.0f;
|
||||
|
||||
// footstep particles (grass shakes via the scene interactor)
|
||||
stepTimer_ -= dt;
|
||||
bool inGrass = zone_.inTallGrass(p);
|
||||
if (predicted_.moving && stepTimer_ <= 0.0f) {
|
||||
stepTimer_ = inGrass ? (predicted_.running ? 0.07f : 0.12f) : (predicted_.running ? 0.16f : 0.25f);
|
||||
glm::vec3 feet(p.x, ground + 0.05f, p.y);
|
||||
if (inGrass) particles_->burst(feet + glm::vec3(0, 0.3f, 0), predicted_.running ? 5 : 3, {0.2f, 0.42f, 0.1f}, 1.6f,
|
||||
0.05f, 0.7f);
|
||||
else if (zone_.onDock(p)) particles_->burst(feet, 1, {0.35f, 0.28f, 0.2f}, 0.4f, 0.04f, 0.3f);
|
||||
else particles_->burst(feet, 2, {0.32f, 0.27f, 0.2f}, 0.7f, 0.06f, 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
void World::sampleEntities(float dt) {
|
||||
float renderTick = serverTick_ - 3.0f; // ~100 ms interpolation delay
|
||||
for (auto& [id, v] : visuals_) {
|
||||
if (v.history.empty()) continue;
|
||||
const Snap* a = &v.history.front();
|
||||
const Snap* b = a;
|
||||
for (size_t i = 0; i + 1 < v.history.size(); ++i)
|
||||
if (v.history[i].tick <= renderTick) {
|
||||
a = &v.history[i];
|
||||
b = &v.history[i + 1];
|
||||
}
|
||||
if (renderTick >= v.history.back().tick) a = b = &v.history.back();
|
||||
float t = b->tick > a->tick ? std::clamp((renderTick - a->tick) / (b->tick - a->tick), 0.0f, 1.0f) : 1.0f;
|
||||
v.lastPos = v.pos;
|
||||
v.pos = glm::mix(a->s.pos, b->s.pos, t);
|
||||
v.yaw = lerpAngle(a->s.yaw, b->s.yaw, t);
|
||||
v.anim = b->s.anim;
|
||||
float moved = glm::length(glm::vec2(v.pos.x - v.lastPos.x, v.pos.z - v.lastPos.z));
|
||||
float sp = dt > 0.0f ? moved / dt : 0.0f;
|
||||
v.speed += (sp - v.speed) * std::min(1.0f, dt * 10.0f);
|
||||
if (v.speed > 0.15f) v.walkPhase += moved * 2.4f;
|
||||
else v.walkPhase = 0.0f;
|
||||
v.flash = std::max(0.0f, v.flash - dt * 2.5f);
|
||||
v.chatAge += dt;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ per frame
|
||||
|
||||
void World::update(float dt, float time, const FrameInput& in, NetClient& net, const hd2d::Camera& cam) {
|
||||
time_ = time;
|
||||
applySnapshots(net);
|
||||
for (const GameEvent& e : net.events) handleEvent(e, net);
|
||||
net.events.clear();
|
||||
predict(dt, in, net, cam);
|
||||
serverTick_ += dt * float(kTickRate);
|
||||
sampleEntities(dt);
|
||||
biteTimer_ = std::max(0.0f, biteTimer_ - dt);
|
||||
|
||||
// auto-target: nearest catchable creature, preferring the facing direction
|
||||
target_ = 0;
|
||||
float best = 1e9f;
|
||||
glm::vec2 me(localRender_.x, localRender_.z), face = dirFromYaw(predicted_.yaw);
|
||||
for (auto& [id, v] : visuals_) {
|
||||
if (v.kind != EntityKind::Wild || v.anim == Anim::Capturing || v.anim == Anim::Flee) continue;
|
||||
glm::vec2 d(v.pos.x - me.x, v.pos.z - me.y);
|
||||
float dist = glm::length(d);
|
||||
if (dist > 8.0f) continue;
|
||||
float facing = dist > 1e-3f ? glm::dot(d / dist, face) : 1.0f;
|
||||
float score = dist * (facing > 0.35f ? 1.0f : 2.2f);
|
||||
if (score < best) best = score, target_ = id;
|
||||
}
|
||||
|
||||
updateDayWeather(dt);
|
||||
updateSprites(dt, cam, &net);
|
||||
particles_->update(dt, time);
|
||||
updateLights();
|
||||
scene.interactor = localRender_;
|
||||
scene.blobShadowPos = glm::vec3(localRender_.x, groundAt(me), localRender_.z);
|
||||
scene.focusPoint = localRender_ + glm::vec3(0, 0.6f, 0);
|
||||
}
|
||||
|
||||
void World::updateBackdrop(float dt, float time, float hour, Weather w) {
|
||||
time_ = time;
|
||||
hour_ = hour;
|
||||
weatherState_ = w;
|
||||
localRender_ = glm::vec3(zone_.plaza.x, 0.0f, zone_.plaza.y);
|
||||
updateDayWeather(dt);
|
||||
hd2d::Camera dummy;
|
||||
updateSprites(dt, dummy, nullptr);
|
||||
particles_->update(dt, time);
|
||||
updateLights();
|
||||
scene.interactor = glm::vec3(-100.0f);
|
||||
scene.blobShadowRadius = 0.0f;
|
||||
scene.focusPoint = localRender_ + glm::vec3(0, 0.6f, 0);
|
||||
}
|
||||
|
||||
void World::updateDayWeather(float dt) {
|
||||
if (hourShown_ < 0.0f) hourShown_ = hour_;
|
||||
float dh = hour_ - hourShown_;
|
||||
if (dh > 12.0f) dh -= 24.0f;
|
||||
if (dh < -12.0f) dh += 24.0f;
|
||||
hourShown_ = std::fmod(hourShown_ + dh * std::min(1.0f, dt * 2.0f) + 24.0f, 24.0f);
|
||||
weather.setClearLook(day.look(hourShown_));
|
||||
float rain = 0, overcast = 0, fog = 0;
|
||||
switch (weatherState_) {
|
||||
case Weather::Cloudy: overcast = 0.85f; break;
|
||||
case Weather::Rain: rain = 0.55f, overcast = 0.7f; break;
|
||||
case Weather::Storm: rain = 1.0f, overcast = 1.0f; break;
|
||||
case Weather::Fog: fog = 1.0f, overcast = 0.35f; break;
|
||||
default: break;
|
||||
}
|
||||
weather.setRain(rain);
|
||||
weather.setOvercast(overcast);
|
||||
weather.setFog(fog);
|
||||
weather.setLightning(weatherState_ == Weather::Storm);
|
||||
}
|
||||
|
||||
void World::updateLights() {
|
||||
scene.lights.clear();
|
||||
float night = day.night(hourShown_);
|
||||
float dusk = std::clamp(1.0f - day.daylight(hourShown_) * 1.6f, 0.0f, 1.0f);
|
||||
float lampOn = std::max(night, dusk * 0.8f);
|
||||
lampOn = std::max(lampOn, weather.overcast() * 0.35f);
|
||||
int lamps = 0;
|
||||
if (lampOn > 0.02f) {
|
||||
std::vector<const hd2d::PointLight*> near;
|
||||
for (const hd2d::PointLight& l : lampLights_) near.push_back(&l);
|
||||
glm::vec3 c = localRender_;
|
||||
std::sort(near.begin(), near.end(), [&](auto* a, auto* b) {
|
||||
return glm::length(a->position - c) < glm::length(b->position - c);
|
||||
});
|
||||
float flicker = 0.92f + 0.08f * std::sin(time_ * 13.0f) * std::sin(time_ * 7.3f);
|
||||
for (size_t i = 0; i < near.size() && i < 8; ++i) {
|
||||
hd2d::PointLight l = *near[i];
|
||||
l.color *= lampOn * flicker;
|
||||
scene.lights.push_back(l);
|
||||
++lamps;
|
||||
}
|
||||
}
|
||||
// Rangers carry a lantern after dark so the trainer and party stay readable (Octopath-style
|
||||
// warm pool of light in a deep blue night).
|
||||
float lantern = std::max(night, weather.rain() * 0.5f * (1.0f - day.daylight(hourShown_)));
|
||||
if (lantern > 0.05f && localSpawned_) {
|
||||
scene.lights.push_back({localRender_ + glm::vec3(0.0f, 1.3f, 0.0f), 5.5f,
|
||||
glm::vec3(2.2f, 1.45f, 0.75f) * lantern});
|
||||
++lamps;
|
||||
}
|
||||
scene.volumetricLights = lamps;
|
||||
particles_->collectLights(scene.lights, 16 - lamps);
|
||||
}
|
||||
|
||||
void World::drawCharacter(hd2d::BillboardBatch& batch, glm::vec3 pos, float yaw, bool moving, bool running,
|
||||
float phase, const hd2d::Camera& cam, int anchorRow, float alpha) {
|
||||
glm::ivec2 texSize = batch.texture.size();
|
||||
bool flip = false;
|
||||
int row = SheetLayout::rowFor(yaw - cam.yaw, flip);
|
||||
int col = moving ? 1 + (int(std::floor(phase)) % manifest_.walkFrames) : 0;
|
||||
hd2d::BillboardInstance& bi = batch.instances[0];
|
||||
bi.uv = hd2d::Texture::cellUV(texSize, kCell, col, row);
|
||||
bi.anchor = hd2d::Texture::cellAnchor(texSize, kCell, row, anchorRow);
|
||||
bi.size = hd2d::BillboardBatch::cellSize(kCell, kPixelsPerUnit);
|
||||
if (!moving) bi.size.y *= 1.0f + 0.012f * std::sin(time_ * 2.4f + pos.x); // breathing
|
||||
float hop = moving && running ? std::abs(std::sin(phase * kPi * 0.5f)) * 0.05f : 0.0f;
|
||||
bi.pos = pos + glm::vec3(0, hop - 0.02f, 0);
|
||||
bi.flip = flip ? 1.0f : 0.0f;
|
||||
bi.alpha = alpha;
|
||||
bi.tint = 1.0f;
|
||||
batch.upload();
|
||||
}
|
||||
|
||||
void World::updateSprites(float dt, const hd2d::Camera& cam, NetClient* net) {
|
||||
scene.billboards = {&tallGrass_, &tufts_, &flowers_};
|
||||
glm::vec3 camRight = cam.right();
|
||||
|
||||
// players (local predicted, remote interpolated)
|
||||
if (net) {
|
||||
if (localSpawned_) {
|
||||
if (auto* b = playerBatch(localId_, *net)) {
|
||||
bool moving = predicted_.moving;
|
||||
drawCharacter(*b, localRender_, predicted_.yaw, moving, predicted_.running, localPhase_, cam,
|
||||
manifest_.bodies.count(playerArt_[localId_].look.body)
|
||||
? manifest_.bodies.at(playerArt_[localId_].look.body).anchorRow
|
||||
: 60);
|
||||
scene.billboards.push_back(b);
|
||||
}
|
||||
}
|
||||
for (auto& [id, v] : visuals_) {
|
||||
if (v.kind != EntityKind::Player || id == localId_) continue;
|
||||
hd2d::BillboardBatch* b = playerBatch(id, *net);
|
||||
if (!b) continue;
|
||||
bool moving = v.anim == Anim::Walk || v.anim == Anim::Run;
|
||||
int anchor = manifest_.bodies.count(playerArt_[id].look.body) ? manifest_.bodies.at(playerArt_[id].look.body).anchorRow : 60;
|
||||
glm::vec3 p = v.pos;
|
||||
p.y = groundAt({p.x, p.z});
|
||||
drawCharacter(*b, p, v.yaw, moving, v.anim == Anim::Run, v.walkPhase, cam, anchor,
|
||||
std::clamp((time_ - v.born) * 3.0f, 0.0f, 1.0f));
|
||||
scene.billboards.push_back(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Professor Henry
|
||||
henry_.instances.clear();
|
||||
for (auto& [id, v] : visuals_)
|
||||
if (v.kind == EntityKind::Npc && henry_.texture.id()) {
|
||||
henry_.instances.resize(1);
|
||||
glm::vec3 p = v.pos;
|
||||
p.y = groundAt({p.x, p.z});
|
||||
int anchor = manifest_.npcs.count("henry") ? manifest_.npcs.at("henry").anchorRow : 60;
|
||||
drawCharacter(henry_, p, v.yaw, v.speed > 0.2f, false, v.walkPhase, cam, anchor);
|
||||
}
|
||||
if (net && henry_.instances.empty()) henry_.upload();
|
||||
if (!net && henry_.texture.id()) { // backdrop: Henry waits at his post
|
||||
henry_.instances.resize(1);
|
||||
hd2d::Camera c = cam;
|
||||
glm::vec2 hp = zone_.henry;
|
||||
drawCharacter(henry_, glm::vec3(hp.x, groundAt(hp), hp.y), zone_.henryYaw, false, false, 0.0f, c,
|
||||
manifest_.npcs.count("henry") ? manifest_.npcs.at("henry").anchorRow : 60);
|
||||
}
|
||||
scene.billboards.push_back(&henry_);
|
||||
|
||||
// creatures (wild + followers) from the shared atlas
|
||||
creatures_.instances.clear();
|
||||
glm::vec2 csize = hd2d::BillboardBatch::cellSize(kCell, kCreaturePixelsPerUnit);
|
||||
for (auto& [id, v] : visuals_) {
|
||||
if (v.kind != EntityKind::Wild && v.kind != EntityKind::Follower) continue;
|
||||
int slot = creatureSlot(v.species);
|
||||
if (slot < 0) continue;
|
||||
// facing: sprites look left by default; mirror when heading right on screen
|
||||
glm::vec2 fd = dirFromYaw(v.yaw);
|
||||
float side = fd.x * camRight.x + fd.y * camRight.z;
|
||||
float want = side > 0.08f ? 1.0f : side < -0.08f ? 0.0f : v.flipTarget;
|
||||
if (want != v.flipTarget) {
|
||||
v.flipTarget = want;
|
||||
v.turnAnim = 1.0f;
|
||||
}
|
||||
v.turnAnim = std::max(0.0f, v.turnAnim - dt / 0.22f);
|
||||
float tt = 1.0f - v.turnAnim;
|
||||
if (tt >= 0.5f) v.flip = v.flipTarget;
|
||||
float turn = v.turnAnim > 0.0f ? std::sin(tt * kPi) * 1.45f : 0.0f;
|
||||
|
||||
hd2d::BillboardInstance bi;
|
||||
bi.uv = creatureAtlas_.uv(slot);
|
||||
bi.anchor = creatureAtlas_.anchor(slot);
|
||||
bi.size = csize;
|
||||
bi.flip = v.flip;
|
||||
bi.turn = turn;
|
||||
bi.flash = v.flash;
|
||||
glm::vec3 p = v.pos;
|
||||
bool water = v.anim == Anim::Swim;
|
||||
float ground = groundAt({p.x, p.z});
|
||||
if (water) p.y = Zone::kWaterLevel - 0.28f + 0.05f * std::sin(time_ * 2.2f + float(id));
|
||||
else if (v.anim == Anim::Hooked) p.y = std::max(p.y, ground);
|
||||
else p.y = ground;
|
||||
bool moving = v.speed > 0.2f && !water;
|
||||
if (moving) {
|
||||
float ph = v.walkPhase * 1.4f;
|
||||
p.y += std::abs(std::sin(ph * kPi)) * 0.16f;
|
||||
bi.size.y *= 1.0f + 0.05f * std::cos(ph * 2.0f * kPi);
|
||||
bi.size.x *= 1.0f - 0.03f * std::cos(ph * 2.0f * kPi);
|
||||
} else {
|
||||
bi.size.y *= 1.0f + 0.018f * std::sin(time_ * 2.0f + float(id) * 1.3f);
|
||||
}
|
||||
float age = time_ - v.born;
|
||||
if (v.anim == Anim::Emerge || age < 0.6f) {
|
||||
float k = easeOutBack(age / 0.5f);
|
||||
bi.size *= std::max(0.05f, k);
|
||||
p.y -= (1.0f - std::clamp(age / 0.5f, 0.0f, 1.0f)) * 0.35f;
|
||||
if (age < 0.15f) bi.flash = std::max(bi.flash, 1.0f - age / 0.15f);
|
||||
}
|
||||
if (v.anim == Anim::Alert && age < 4.0f) p.y += std::max(0.0f, std::sin((time_ - v.born) * 9.0f)) * 0.05f;
|
||||
if (v.anim == Anim::Flee) bi.alpha = 0.75f;
|
||||
if (v.anim == Anim::Capturing) {
|
||||
// vanish into the capsule once it lands
|
||||
for (const Capsule& c : capsules_)
|
||||
if (c.target == id && time_ > c.start + c.flight) {
|
||||
v.hide = std::min(1.0f, v.hide + dt * 4.0f);
|
||||
v.flash = std::max(v.flash, 1.0f - v.hide);
|
||||
}
|
||||
bi.size *= 1.0f - 0.8f * v.hide;
|
||||
bi.alpha = 1.0f - v.hide;
|
||||
bi.flash = std::max(bi.flash, v.flash);
|
||||
} else {
|
||||
v.hide = 0.0f;
|
||||
}
|
||||
bi.pos = p;
|
||||
bi.tint = 1.0f;
|
||||
if (bi.alpha > 0.01f) creatures_.instances.push_back(bi);
|
||||
}
|
||||
creatures_.upload();
|
||||
scene.billboards.push_back(&creatures_);
|
||||
|
||||
// effects: capsules, bobbers + fishing lines
|
||||
fx_.instances.clear();
|
||||
glm::vec2 fxSize = hd2d::BillboardBatch::cellSize({16, 16}, kPixelsPerUnit);
|
||||
for (auto it = capsules_.begin(); it != capsules_.end();) {
|
||||
Capsule& c = *it;
|
||||
float t = (time_ - c.start) / c.flight;
|
||||
hd2d::BillboardInstance bi;
|
||||
bi.uv = fxAtlas_.uv(fxCapsule_);
|
||||
bi.anchor = fxAtlas_.anchor(fxCapsule_);
|
||||
bi.size = fxSize;
|
||||
glm::vec3 land = c.to;
|
||||
land.y = groundAt({c.to.x, c.to.z});
|
||||
if (const Visual* v = visuals_.count(c.target) ? &visuals_[c.target] : nullptr; v && v->anim == Anim::Swim)
|
||||
land.y = Zone::kWaterLevel + 0.02f;
|
||||
if (t < 1.0f) {
|
||||
float h = 1.0f + glm::length(c.to - c.from) * 0.08f;
|
||||
bi.pos = glm::mix(c.from, land, t) + glm::vec3(0, 4.0f * h * t * (1.0f - t), 0);
|
||||
bi.flip = std::fmod(time_ * 12.0f, 2.0f) < 1.0f ? 1.0f : 0.0f;
|
||||
} else {
|
||||
bi.pos = land;
|
||||
float since = time_ - (c.start + c.flight);
|
||||
float cyc = std::fmod(since, 0.55f) / 0.55f;
|
||||
if (c.result < 0 || c.result == 1) bi.turn = since > 0.3f && cyc < 0.4f ? std::sin(cyc / 0.4f * 2.0f * kPi) * 0.6f : 0.0f;
|
||||
if (c.result == 1) {
|
||||
float k = time_ - c.resultAt;
|
||||
bi.turn = 0.0f;
|
||||
bi.flash = std::max(0.0f, 1.0f - k * 3.0f);
|
||||
bi.alpha = std::clamp(1.4f - k, 0.0f, 1.0f);
|
||||
if (k > 1.4f) {
|
||||
it = capsules_.erase(it);
|
||||
continue;
|
||||
}
|
||||
} else if (c.result == 0 || time_ - c.start > 12.0f) {
|
||||
it = capsules_.erase(it);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fx_.instances.push_back(bi);
|
||||
++it;
|
||||
}
|
||||
for (const Bobber& b : bobbers_) {
|
||||
hd2d::BillboardInstance bi;
|
||||
bi.uv = fxAtlas_.uv(fxBobber_);
|
||||
bi.anchor = fxAtlas_.anchor(fxBobber_);
|
||||
bi.size = fxSize * 1.25f;
|
||||
bool biting = b.bite >= 0.0f && time_ - b.bite < 1.3f;
|
||||
float dip = biting ? -0.06f - 0.05f * std::abs(std::sin(time_ * 18.0f)) : 0.015f * std::sin(time_ * 3.0f);
|
||||
bi.pos = glm::vec3(b.pos.x, Zone::kWaterLevel + dip - 0.04f, b.pos.z);
|
||||
fx_.instances.push_back(bi);
|
||||
// fishing line: dots from the angler's hand with a little sag
|
||||
glm::vec3 hand;
|
||||
if (net && b.player == localId_) hand = localRender_;
|
||||
else if (auto v = visuals_.find(b.player); v != visuals_.end()) hand = v->second.pos;
|
||||
else continue;
|
||||
glm::vec2 face = glm::normalize(glm::vec2(b.pos.x - hand.x, b.pos.z - hand.z) + glm::vec2(1e-4f));
|
||||
hand += glm::vec3(face.x * 0.35f, 1.25f, face.y * 0.35f);
|
||||
glm::vec3 end = bi.pos + glm::vec3(0, 0.12f, 0);
|
||||
for (int i = 1; i < 28; ++i) {
|
||||
float s = float(i) / 28.0f;
|
||||
hd2d::BillboardInstance d;
|
||||
d.uv = fxAtlas_.uv(fxDot_);
|
||||
d.anchor = fxAtlas_.anchor(fxDot_);
|
||||
d.size = fxSize * 0.6f;
|
||||
d.pos = glm::mix(hand, end, s) - glm::vec3(0, (biting ? 0.1f : 0.45f) * 4.0f * s * (1.0f - s), 0);
|
||||
d.tint = 0.9f;
|
||||
fx_.instances.push_back(d);
|
||||
}
|
||||
}
|
||||
fx_.upload();
|
||||
scene.billboards.push_back(&fx_);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ queries
|
||||
|
||||
uint32_t World::nearbyNpc() const {
|
||||
glm::vec2 me(localRender_.x, localRender_.z);
|
||||
for (const auto& [id, v] : visuals_)
|
||||
if (v.kind == EntityKind::Npc && glm::length(glm::vec2(v.pos.x, v.pos.z) - me) < 2.5f) return id;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool World::canFish(glm::vec2& bobber) const {
|
||||
return zone_.fishingTarget({localRender_.x, localRender_.z}, dirFromYaw(predicted_.yaw), bobber);
|
||||
}
|
||||
|
||||
bool World::lineOut() const {
|
||||
for (const Bobber& b : bobbers_)
|
||||
if (b.player == localId_) return true;
|
||||
return localServerAnim_ == Anim::Fish;
|
||||
}
|
||||
|
||||
void World::showChatBubble(uint32_t entity, const std::string& text) {
|
||||
if (auto it = visuals_.find(entity); it != visuals_.end()) {
|
||||
it->second.chat = text;
|
||||
it->second.chatAge = 0.0f;
|
||||
} else if (entity == localId_) {
|
||||
Visual& v = visuals_[entity]; // placeholder so the local bubble has a home
|
||||
v.chat = text;
|
||||
v.chatAge = 0.0f;
|
||||
v.lastSeen = time_ + 6.0f;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<World::Label> World::labels() const {
|
||||
std::vector<Label> out;
|
||||
for (const auto& [id, v] : visuals_) {
|
||||
glm::vec3 head = v.pos;
|
||||
if (v.kind == EntityKind::Player && id != localId_) {
|
||||
head.y = groundAt({v.pos.x, v.pos.z}) + 1.75f;
|
||||
out.push_back({head, "", 0x8ec8ff, false, id});
|
||||
} else if (v.kind == EntityKind::Npc) {
|
||||
head.y = groundAt({v.pos.x, v.pos.z}) + 1.8f;
|
||||
out.push_back({head, "Professor Henry", 0xe6be5a});
|
||||
} else if (id == target_) {
|
||||
const Species* sp = roster_.get(v.species);
|
||||
head.y = (v.anim == Anim::Swim ? Zone::kWaterLevel : groundAt({v.pos.x, v.pos.z})) + 1.45f;
|
||||
out.push_back({head, sp ? sp->name : "???", sp ? typeColor(sp->type) : 0xffffffu});
|
||||
} else if (v.kind == EntityKind::Wild && v.anim == Anim::Alert && time_ - v.born < 3.5f) {
|
||||
head.y = groundAt({v.pos.x, v.pos.z}) + 1.45f;
|
||||
out.push_back({head, "!", 0xff6a4a});
|
||||
}
|
||||
if (v.chatAge < 5.0f && !v.chat.empty()) {
|
||||
glm::vec3 p = id == localId_ ? localRender_ : v.pos;
|
||||
p.y = groundAt({p.x, p.z}) + 2.2f;
|
||||
out.push_back({p, v.chat, 0xffffff, true, id});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
193
client/World.h
Normal file
193
client/World.h
Normal file
@@ -0,0 +1,193 @@
|
||||
#pragma once
|
||||
// Client view of a safari zone: builds the HD-2D diorama from the shared
|
||||
// Zone layout, predicts the local player, interpolates every networked
|
||||
// entity, and turns server events into effects (grass bursts, splashes,
|
||||
// capsule throws, fishing bobbers).
|
||||
|
||||
#include "CharacterSprite.h"
|
||||
#include "Common.h"
|
||||
#include "Movement.h"
|
||||
#include "NetClient.h"
|
||||
#include "Roster.h"
|
||||
#include "Zone.h"
|
||||
|
||||
#include "hd2d/Billboard.h"
|
||||
#include "hd2d/Camera.h"
|
||||
#include "hd2d/DayCycle.h"
|
||||
#include "hd2d/GroundOcclusion.h"
|
||||
#include "hd2d/Model.h"
|
||||
#include "hd2d/Particles.h"
|
||||
#include "hd2d/Scene.h"
|
||||
#include "hd2d/SpriteAtlas.h"
|
||||
#include "hd2d/Terrain.h"
|
||||
#include "hd2d/Water.h"
|
||||
#include "hd2d/Weather.h"
|
||||
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
struct FrameInput {
|
||||
glm::vec2 move{0.0f}; // camera-relative: x = screen right, y = screen up
|
||||
bool run = false;
|
||||
};
|
||||
|
||||
class World {
|
||||
public:
|
||||
bool init(); // static assets (models, textures, character art, roster); once per process
|
||||
bool buildZone(const std::string& id, uint32_t seed);
|
||||
const Zone& zone() const { return zone_; }
|
||||
bool zoneBuilt() const { return built_; }
|
||||
|
||||
// Online: consume snapshots/events and predict the local player.
|
||||
void update(float dt, float time, const FrameInput& in, NetClient& net, const hd2d::Camera& cam);
|
||||
// Offline backdrop (title screen): just animate the scene at a fixed hour.
|
||||
void updateBackdrop(float dt, float time, float hour, Weather w);
|
||||
void reset(); // forget all entities (on disconnect)
|
||||
|
||||
// Gameplay queries for the HUD / input handling.
|
||||
glm::vec3 localPos() const { return localRender_; }
|
||||
float localYaw() const { return predicted_.yaw; }
|
||||
bool localSpawned() const { return localSpawned_; }
|
||||
uint32_t targetCreature() const { return target_; }
|
||||
uint32_t nearbyNpc() const; // Henry when in talking range, else 0
|
||||
bool canFish(glm::vec2& bobber) const;
|
||||
bool lineOut() const; // the local player's fishing line is in the water
|
||||
bool biting() const { return biteTimer_ > 0.0f; }
|
||||
float hour() const { return hour_; }
|
||||
Weather weatherState() const { return weatherState_; }
|
||||
|
||||
// Character preview / creator.
|
||||
const CharacterArt& characterArt() const { return charArt_; }
|
||||
const CharacterManifest& manifest() const { return manifest_; }
|
||||
const Roster& roster() const { return roster_; }
|
||||
|
||||
// Screen-space labels (players, Henry, the targeted creature).
|
||||
struct Label {
|
||||
glm::vec3 world;
|
||||
std::string text;
|
||||
uint32_t accent;
|
||||
bool chat = false;
|
||||
uint32_t entity = 0; // players: App fills the name from NetClient::players
|
||||
};
|
||||
std::vector<Label> labels() const;
|
||||
void showChatBubble(uint32_t entity, const std::string& text);
|
||||
|
||||
hd2d::Scene scene;
|
||||
hd2d::Weather weather;
|
||||
hd2d::DayCycle day;
|
||||
|
||||
private:
|
||||
struct Snap {
|
||||
float tick;
|
||||
EntityState s;
|
||||
};
|
||||
struct Visual {
|
||||
uint32_t id = 0;
|
||||
EntityKind kind = EntityKind::Wild;
|
||||
uint16_t species = 0;
|
||||
uint32_t owner = 0;
|
||||
std::deque<Snap> history;
|
||||
glm::vec3 pos{0.0f}, lastPos{0.0f};
|
||||
float yaw = 0.0f;
|
||||
Anim anim = Anim::Idle;
|
||||
float walkPhase = 0.0f, speed = 0.0f;
|
||||
float flip = 0.0f, flipTarget = 0.0f, turnAnim = 0.0f; // paper-turn when flipping
|
||||
float born = -1.0f, lastSeen = 0.0f, flash = 0.0f, hide = 0.0f;
|
||||
int atlasSlot = -1;
|
||||
std::string chat;
|
||||
float chatAge = 1e9f;
|
||||
};
|
||||
struct PlayerArt {
|
||||
Appearance look;
|
||||
std::unique_ptr<hd2d::BillboardBatch> batch;
|
||||
};
|
||||
struct Capsule {
|
||||
uint32_t thrower = 0, target = 0;
|
||||
glm::vec3 from, to;
|
||||
float start = 0.0f, flight = 0.5f;
|
||||
int result = -1; // -1 pending, 0 fail, 1 success
|
||||
float resultAt = 0.0f;
|
||||
};
|
||||
struct Bobber {
|
||||
uint32_t player = 0;
|
||||
glm::vec3 pos;
|
||||
float bite = -1.0f; // time of the last bite
|
||||
};
|
||||
|
||||
void buildTerrain();
|
||||
void buildProps();
|
||||
void buildDecor();
|
||||
void applySnapshots(NetClient& net);
|
||||
void handleEvent(const GameEvent& e, NetClient& net);
|
||||
void predict(float dt, const FrameInput& in, NetClient& net, const hd2d::Camera& cam);
|
||||
void sampleEntities(float dt);
|
||||
void updateDayWeather(float dt);
|
||||
void updateSprites(float dt, const hd2d::Camera& cam, NetClient* net);
|
||||
void updateLights();
|
||||
int creatureSlot(uint16_t species);
|
||||
hd2d::BillboardBatch* playerBatch(uint32_t entity, const NetClient& net);
|
||||
void drawCharacter(hd2d::BillboardBatch& batch, glm::vec3 pos, float yaw, bool moving, bool running,
|
||||
float phase, const hd2d::Camera& cam, int anchorRow, float alpha = 1.0f);
|
||||
float groundAt(glm::vec2 p) const { return zone_.groundHeight(p); }
|
||||
|
||||
// static assets
|
||||
hd2d::ModelLibrary scenery_, safari_;
|
||||
CharacterManifest manifest_;
|
||||
CharacterArt charArt_;
|
||||
Roster roster_;
|
||||
hd2d::Image henryImage_;
|
||||
bool assetsLoaded_ = false;
|
||||
|
||||
// zone
|
||||
Zone zone_;
|
||||
bool built_ = false;
|
||||
hd2d::Terrain terrain_;
|
||||
hd2d::Water water_;
|
||||
std::unique_ptr<hd2d::ParticleSystem> particles_;
|
||||
hd2d::GroundOcclusion groundAO_;
|
||||
std::map<std::string, std::unique_ptr<hd2d::ModelBatch>> models_;
|
||||
hd2d::BillboardBatch tallGrass_, tufts_, flowers_;
|
||||
std::vector<hd2d::PointLight> lampLights_;
|
||||
|
||||
// entities
|
||||
std::unordered_map<uint32_t, Visual> visuals_;
|
||||
std::unordered_map<uint32_t, PlayerArt> playerArt_;
|
||||
hd2d::BillboardBatch henry_;
|
||||
hd2d::SpriteAtlas creatureAtlas_;
|
||||
hd2d::BillboardBatch creatures_;
|
||||
std::unordered_map<uint16_t, int> speciesSlot_;
|
||||
std::unordered_map<int, float> slotUsed_; // LRU stamp
|
||||
hd2d::SpriteAtlas fxAtlas_;
|
||||
hd2d::BillboardBatch fx_;
|
||||
int fxCapsule_ = -1, fxBobber_ = -1, fxDot_ = -1;
|
||||
std::vector<Capsule> capsules_;
|
||||
std::vector<Bobber> bobbers_;
|
||||
|
||||
// local prediction
|
||||
uint32_t localId_ = 0;
|
||||
bool localSpawned_ = false;
|
||||
MoveState predicted_, prevPredicted_;
|
||||
std::deque<InputCmd> pending_;
|
||||
uint32_t inputSeq_ = 0;
|
||||
float tickAccum_ = 0.0f;
|
||||
glm::vec2 correction_{0.0f};
|
||||
glm::vec3 localRender_{0.0f};
|
||||
float localPhase_ = 0.0f;
|
||||
Anim localServerAnim_ = Anim::Idle;
|
||||
float serverTick_ = 0.0f;
|
||||
bool haveTick_ = false;
|
||||
uint32_t target_ = 0;
|
||||
float biteTimer_ = 0.0f;
|
||||
float stepTimer_ = 0.0f;
|
||||
|
||||
float time_ = 0.0f;
|
||||
float hour_ = 12.0f, hourShown_ = -1.0f;
|
||||
Weather weatherState_ = Weather::Clear;
|
||||
};
|
||||
|
||||
} // namespace bs
|
||||
60
client/main.cpp
Normal file
60
client/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
// Brandling Safari client.
|
||||
//
|
||||
// safari_client title screen (Host & Play / Join a LAN game)
|
||||
// safari_client --host start a local server and join it
|
||||
// safari_client --connect 192.168.1.20 join a server directly
|
||||
//
|
||||
// Headless captures (hidden window, nothing pops up, input ignored):
|
||||
// safari_client --screenshot out.png --frames 240 --connect 127.0.0.1 --auto-create --profile bot1
|
||||
// [--move x,y] [--run] [--yaw deg] [--dist d] [--key 120:space] [--screen creator]
|
||||
// [--look female,2,hair_long,hat_cap,top_hoodie,bottom_pants,shoes_boots] [--hour 18] [--weather rain]
|
||||
|
||||
#include "App.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
bool pair(const char* s, float& a, float& b) { return std::sscanf(s, "%f,%f", &a, &b) == 2; }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
bs::Options o;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
auto is = [&](const char* f) { return std::strcmp(argv[i], f) == 0; };
|
||||
auto next = [&]() -> const char* { return i + 1 < argc ? argv[++i] : ""; };
|
||||
if (is("--screenshot")) o.screenshot = next();
|
||||
else if (is("--frames")) o.frames = std::atoi(next());
|
||||
else if (is("--size")) std::sscanf(next(), "%dx%d", &o.width, &o.height);
|
||||
else if (is("--show")) o.show = true;
|
||||
else if (is("--connect")) o.connect = next();
|
||||
else if (is("--host")) o.host = true;
|
||||
else if (is("--port")) o.port = uint16_t(std::atoi(next()));
|
||||
else if (is("--auto-create")) o.autoCreate = true;
|
||||
else if (is("--auto-dialogue")) o.autoDialogue = true;
|
||||
else if (is("--screen")) o.screen = next();
|
||||
else if (is("--profile")) o.profile = next();
|
||||
else if (is("--name")) o.name = next();
|
||||
else if (is("--move")) pair(next(), o.move.x, o.move.y);
|
||||
else if (is("--run")) o.run = true;
|
||||
else if (is("--yaw")) o.yaw = float(std::atof(next())), o.hasYaw = true;
|
||||
else if (is("--dist")) o.dist = float(std::atof(next()));
|
||||
else if (is("--hour")) o.hour = float(std::atof(next()));
|
||||
else if (is("--weather")) o.weather = next();
|
||||
else if (is("--key")) o.keys.push_back(next());
|
||||
else if (is("--look")) o.look = next();
|
||||
else if (is("--help") || is("-h")) {
|
||||
std::puts("safari_client [--host | --connect host[:port]] [--port N] [--profile name]\n"
|
||||
" captures: --screenshot out.png [--frames N] [--size WxH] [--show] [--auto-create] [--name N]\n"
|
||||
" [--move x,y] [--run] [--yaw deg] [--dist d] [--key frame:space|e|f|tab|enter]\n"
|
||||
" [--screen title|creator] [--look body,skin,hair,hat,top,bottom,shoes[,colors...]]\n"
|
||||
" [--hour H] [--weather clear|cloudy|rain|storm|fog]");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
bs::App app(o);
|
||||
return app.run();
|
||||
}
|
||||
Reference in New Issue
Block a user