From df4338d4c842c8a720df05e9bad7d0d7a179e28d Mon Sep 17 00:00:00 2001 From: Jeod <47716344+JeodC@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:24:53 -0400 Subject: [PATCH] Add anchor color customization and nametags --- src/core2/ba/model.c | 4 + src/port/Engine.cpp | 7 +- .../Enhancements/Cosmetics/PlayerColors.cpp | 480 ++++++++++++++++++ .../Enhancements/Cosmetics/PlayerColors.h | 43 ++ src/port/Game.cpp | 3 + src/port/Nametag/Nametag.cpp | 172 ++++++- src/port/Nametag/Nametag.h | 11 +- src/port/Nametag/NametagBindings.cpp | 48 +- src/port/Network/Anchor/Anchor.cpp | 48 ++ src/port/Network/Anchor/Anchor.h | 5 +- src/port/Network/Anchor/DummyPlayer.cpp | 2 + src/port/Network/Anchor/DummyPlayer.h | 10 + src/port/Network/Anchor/JsonConversions.hpp | 33 ++ src/port/Network/Anchor/Menu.cpp | 93 +++- .../Network/Anchor/Packets/AllClientState.cpp | 6 + .../Anchor/Packets/UpdateClientState.cpp | 6 + src/port/UI/LighthouseMenuDevTools.cpp | 3 - src/port/UI/UIWidgets.cpp | 5 +- 18 files changed, 903 insertions(+), 76 deletions(-) create mode 100644 src/port/Enhancements/Cosmetics/PlayerColors.cpp create mode 100644 src/port/Enhancements/Cosmetics/PlayerColors.h diff --git a/src/core2/ba/model.c b/src/core2/ba/model.c index 4b16065fd..286240997 100644 --- a/src/core2/ba/model.c +++ b/src/core2/ba/model.c @@ -7,6 +7,8 @@ #include "core2/ba/physics.h" #include "bk_math.h" +#include "port/Enhancements/Cosmetics/PlayerColors.h" + void assetcache_release(void *); //assetcache_free void modelRender_func_8033A280(f32); @@ -101,6 +103,8 @@ void baModel_draw(Gfx **gfx, Mtx **mtx, Vtx **vtx){ modelRender_setPreDrawCallback((model_render_pre_draw_callback_f)_baModel_preDraw, 0); modelRender_setRefPoints(D_80363780); modelRender_setDepthMode(MODEL_RENDER_DEPTH_FULL); + // [port] Swap in this player's recoloured palettes/vertex colours for this draw. + PlayerColors_applyLocalForDraw(baModel_getModelId(), baModelBin); if(D_8037C150.unk0){ D_8037C150.unk0 = 0; modelRender_draw(gfx, mtx, D_8037C150.unk4, rotation, baModelScale, sp38, baModelBin); diff --git a/src/port/Engine.cpp b/src/port/Engine.cpp index 7759f3d39..75132ca07 100644 --- a/src/port/Engine.cpp +++ b/src/port/Engine.cpp @@ -35,6 +35,7 @@ #include "Extractor/GameExtractor.h" #include "ship/window/gui/FileBrowserWindow.h" #include "Interpolation/FrameInterpolation.h" +#include "Nametag/Nametag.h" #include "OS/OS.h" #include "Network/Anchor/Anchor.h" #include "port/Enhancements/Events/PortEnhancements.h" @@ -1269,9 +1270,13 @@ void GameEngine::RunCommands(Gfx* Commands, const std::vector 1) ? (float)(frameIdx + 1) / (float)frameCount : 1.0f; if (frameCount > 1) { - FrameInterpolation_ApplyAnimVertices((float)(frameIdx + 1) / (float)frameCount); + FrameInterpolation_ApplyAnimVertices(subframeBlend); } + // Overlays drawn by the Gui pass below place themselves per tick, so they need the + // same blend the geometry is being posed with or they step while the world glides. + Nametag::SetSubframeBlend(subframeBlend); bool isFinalFrame = (frameIdx == frameCount - 1); if (frameCount > 1 || wndBase->IsFrameReady()) { // Sample the full CPU cost of producing this sub-frame. diff --git a/src/port/Enhancements/Cosmetics/PlayerColors.cpp b/src/port/Enhancements/Cosmetics/PlayerColors.cpp new file mode 100644 index 000000000..e625e0971 --- /dev/null +++ b/src/port/Enhancements/Cosmetics/PlayerColors.cpp @@ -0,0 +1,480 @@ +#include "PlayerColors.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "port/UI/cvar_prefixes.h" + +extern "C" { +#include "model.h" +#include "enums.h" +#include "core2/modelRender.h" +s32 port_anchor_isConnected(void); +} + +namespace { + +// --------------------------------------------------------------------------- +// Part tables +// +// Derived from the two player model bins: every CI4 texture and every distinct +// vertex colour was attributed to a bone (via the geo layout) and to a body +// region, then grouped by part. Textures are listed per model because the +// low- and high-poly bins have different texture counts and orderings. +// --------------------------------------------------------------------------- + +struct ChannelInfo { + u8 vanilla[3]; + float minSaturation; + float minHueDeg, maxHueDeg; +}; + +const ChannelInfo kChannels[] = { + /* BANJO_FUR */ { { 0x83, 0x32, 0x03 }, 0.50f, 5.0f, 50.0f }, + /* BANJO_SHORTS */ { { 0xFF, 0xF6, 0x20 }, 0.50f, 0.0f, 70.0f }, + /* KAZOOIE_FEATHERS */ { { 0xF6, 0x10, 0x00 }, 0.50f, 0.0f, 70.0f }, + /* KAZOOIE_BEAK */ { { 0xFE, 0xA1, 0x31 }, 0.12f, 0.0f, 70.0f }, + /* BANJO_BACKPACK */ { { 0x00, 0x4A, 0xE0 }, 0.35f, 190.0f, 250.0f }, + /* BANJO_SKIN */ { { 0xEB, 0x83, 0x6D }, 0.15f, 0.0f, 60.0f }, +}; + +struct TextureTable { + const s16* index; + s32 count; +}; + +// ASSET_34D_MODEL_BANJOKAZOOIE_LOW_POLY +const s16 kLowFur[] = { 12, 13, 15, 18 }; +const s16 kLowShorts[] = { 5, 6 }; +const s16 kLowFeathers[] = { 14 }; +const s16 kLowBeak[] = { 19, 20 }; +const s16 kLowSkin[] = { 2, 7 }; + +// ASSET_34E_MODEL_BANJOKAZOOIE_HIGH_POLY +const s16 kHighFur[] = { 13, 14, 16, 19 }; +const s16 kHighShorts[] = { 6, 7 }; +const s16 kHighFeathers[] = { 15 }; +const s16 kHighBeak[] = { 20, 21 }; +const s16 kHighSkin[] = { 3, 8 }; + +#define TEXTABLE(array) \ + { array, (s32)(sizeof(array) / sizeof((array)[0])) } + +// The backpack is shaded entirely by vertex colour. The only blue palettes in +// the model belong to the eyes and Banjo's claws, which must stay put. +const TextureTable kLowTextures[] = { TEXTABLE(kLowFur), TEXTABLE(kLowShorts), TEXTABLE(kLowFeathers), + TEXTABLE(kLowBeak), { nullptr, 0 }, TEXTABLE(kLowSkin) }; +const TextureTable kHighTextures[] = { TEXTABLE(kHighFur), TEXTABLE(kHighShorts), TEXTABLE(kHighFeathers), + TEXTABLE(kHighBeak), { nullptr, 0 }, TEXTABLE(kHighSkin) }; + +#undef TEXTABLE + +const u32 kFurVerts[] = { 0xFA6F12, 0xCC560B, 0xAE4707, 0x833203, 0x632501, 0x411700, 0x270E00 }; +const u32 kShortsVerts[] = { 0xFFFF28, 0xEAEA00, 0xF6B400, 0xCE8700, 0xBA5500, 0x874708 }; +const u32 kFeatherVerts[] = { 0xFF3300, 0xD00000, 0x880000, 0x520000, 0xFF5F00, 0xFF8900 }; +const u32 kBeakVerts[] = { 0xFFFF9F, 0xFFCF3D, 0xFFC700, 0xFEA131, 0xBA7F4B, 0x923900, 0xC74700, 0x662300 }; +const u32 kBackpackVerts[] = { 0x7FA9FF, 0x558DFF, 0x2B71FF, 0x004AE0, 0x003092, 0x002268, 0x001A4E }; +const u32 kSkinVerts[] = { 0xFFEAB4, 0xFFBE97, 0xFFA385, 0xEB836D, 0xC66E5C, 0x995447, 0x66372F, 0x45251F }; + +struct VertexTable { + const u32* color; + s32 count; +}; + +#define VTXTABLE(array) \ + { array, (s32)(sizeof(array) / sizeof((array)[0])) } +const VertexTable kVertexTables[] = { VTXTABLE(kFurVerts), VTXTABLE(kShortsVerts), VTXTABLE(kFeatherVerts), + VTXTABLE(kBeakVerts), VTXTABLE(kBackpackVerts), VTXTABLE(kSkinVerts) }; +#undef VTXTABLE + +// --------------------------------------------------------------------------- +// Color math +// --------------------------------------------------------------------------- + +struct Hsv { + float hue; // degrees, 0..360 + float saturation, value; +}; + +Hsv ToHsv(float red, float green, float blue) { + const float maxComponent = std::max({ red, green, blue }); + const float minComponent = std::min({ red, green, blue }); + const float chroma = maxComponent - minComponent; + Hsv hsv{ 0.0f, maxComponent > 0.0f ? chroma / maxComponent : 0.0f, maxComponent }; + if (chroma > 0.0f) { + if (maxComponent == red) { + hsv.hue = 60.0f * std::fmod((green - blue) / chroma, 6.0f); + } else if (maxComponent == green) { + hsv.hue = 60.0f * (((blue - red) / chroma) + 2.0f); + } else { + hsv.hue = 60.0f * (((red - green) / chroma) + 4.0f); + } + if (hsv.hue < 0.0f) { + hsv.hue += 360.0f; + } + } + return hsv; +} + +void FromHsv(const Hsv& hsv, float* red, float* green, float* blue) { + const float chroma = hsv.value * hsv.saturation; + const float sector = std::fmod(std::fmod(hsv.hue, 360.0f) + 360.0f, 360.0f) / 60.0f; + const float ramp = chroma * (1.0f - std::fabs(std::fmod(sector, 2.0f) - 1.0f)); + float highest = 0.0f, middle = 0.0f, lowest = 0.0f; + switch ((int)sector) { + case 0: + highest = chroma; + middle = ramp; + break; + case 1: + highest = ramp; + middle = chroma; + break; + case 2: + middle = chroma; + lowest = ramp; + break; + case 3: + middle = ramp; + lowest = chroma; + break; + case 4: + highest = ramp; + lowest = chroma; + break; + default: + highest = chroma; + lowest = ramp; + break; + } + const float offset = hsv.value - chroma; + *red = highest + offset; + *green = middle + offset; + *blue = lowest + offset; +} + +float Clamp01(float value) { + return value < 0.0f ? 0.0f : (value > 1.0f ? 1.0f : value); +} + +// Re-tints one source colour so the channel's vanilla colour lands exactly on `target` and +// every other shade of the part keeps its position in the shading ramp. +void Recolor(const ChannelInfo& channel, const BKColorChannelSetting& target, u8 sourceRed, u8 sourceGreen, + u8 sourceBlue, u8* outRed, u8* outGreen, u8* outBlue) { + const Hsv source = ToHsv(sourceRed / 255.0f, sourceGreen / 255.0f, sourceBlue / 255.0f); + const Hsv vanilla = ToHsv(channel.vanilla[0] / 255.0f, channel.vanilla[1] / 255.0f, channel.vanilla[2] / 255.0f); + const Hsv chosen = ToHsv(target.r / 255.0f, target.g / 255.0f, target.b / 255.0f); + + float hueOffset = source.hue - vanilla.hue; + while (hueOffset > 180.0f) { + hueOffset -= 360.0f; + } + while (hueOffset < -180.0f) { + hueOffset += 360.0f; + } + const float rampPosition = std::min(std::fabs(hueOffset), 45.0f) / 45.0f; + + Hsv result; + result.hue = chosen.hue + (hueOffset < 0.0f ? -1.0f : 1.0f) * rampPosition * 8.0f; + result.saturation = + vanilla.saturation > 0.001f ? chosen.saturation * (source.saturation / vanilla.saturation) : chosen.saturation; + result.value = vanilla.value > 0.001f ? chosen.value * (source.value / vanilla.value) : chosen.value; + result.saturation = Clamp01(result.saturation * (1.0f - 0.55f * rampPosition)); + result.value = Clamp01(result.value * (1.0f + 0.55f * rampPosition)); + + float red, green, blue; + FromHsv(result, &red, &green, &blue); + *outRed = (u8)std::lround(Clamp01(red) * 255.0f); + *outGreen = (u8)std::lround(Clamp01(green) * 255.0f); + *outBlue = (u8)std::lround(Clamp01(blue) * 255.0f); +} + +bool PassesFilter(const ChannelInfo& channel, u8 red, u8 green, u8 blue) { + const Hsv hsv = ToHsv(red / 255.0f, green / 255.0f, blue / 255.0f); + if (hsv.value <= 0.0f || hsv.saturation < channel.minSaturation) { + return false; + } + return hsv.hue >= channel.minHueDeg && hsv.hue <= channel.maxHueDeg; +} + +// --------------------------------------------------------------------------- +// Per-owner recolored copies +// --------------------------------------------------------------------------- + +constexpr size_t kPaletteArenaWords = 128 * 1024; // 1 MiB + +void* PaletteArenaTake(size_t bytes) { + static std::unique_ptr arena; + static size_t usedWords = 0; + + const size_t wordCount = (bytes + 7) / 8; + if (wordCount > kPaletteArenaWords) { + return nullptr; + } + if (arena == nullptr) { + arena = std::make_unique(kPaletteArenaWords); + } + if (usedWords + wordCount > kPaletteArenaWords) { + usedWords = 0; + } + u64* region = arena.get() + usedWords; + usedWords += wordCount; + return region; +} + +struct Variant { + const void* sourceBin = nullptr; + u64 colorHash = 0; + // Palettes live in the ring arena above; vertices are read fresh every draw and are not + // cached by address, so they can be rebuilt in place. + void* textures = nullptr; + std::unique_ptr vertices; +}; + +std::unordered_map sVariants; + +u64 VariantKey(u32 ownerKey, s32 modelId) { + return ((u64)ownerKey << 32) | (u32)modelId; +} + +u64 HashColors(const BKPlayerColorSet& colors) { + u64 hash = 1469598103934665603ull; + const u8* bytes = (const u8*)&colors; + for (size_t index = 0; index < sizeof(colors); index++) { + hash = (hash ^ bytes[index]) * 1099511628211ull; + } + return hash; +} + +bool AnyEnabled(const BKPlayerColorSet& colors) { + for (int channelIndex = 0; channelIndex < BK_COLOR_CHANNEL_COUNT; channelIndex++) { + if (colors.channel[channelIndex].enabled) { + return true; + } + } + return false; +} + +const TextureTable* TexturesForModel(s32 modelId) { + switch (modelId) { + case ASSET_34D_MODEL_BANJOKAZOOIE_LOW_POLY: + return kLowTextures; + case ASSET_34E_MODEL_BANJOKAZOOIE_HIGH_POLY: + return kHighTextures; + default: + return nullptr; + } +} + +void RecolorTextures(void* textureBlob, const TextureTable* textureTable, const BKPlayerColorSet& colors) { + BKTextureList* textureList = (BKTextureList*)textureBlob; + u8* pixelBase = (u8*)textureList + sizeof(BKTextureList) + (size_t)textureList->count * sizeof(BKTextureInfo); + + for (int channelIndex = 0; channelIndex < BK_COLOR_CHANNEL_COUNT; channelIndex++) { + if (!colors.channel[channelIndex].enabled) { + continue; + } + const ChannelInfo& channel = kChannels[channelIndex]; + for (s32 entry = 0; entry < textureTable[channelIndex].count; entry++) { + const s16 textureIndex = textureTable[channelIndex].index[entry]; + if (textureIndex < 0 || textureIndex >= textureList->count) { + continue; + } + BKTextureInfo* texture = &textureList->texture_infos[textureIndex]; + const s32 paletteBytes = textureInfo_getPaletteSize(texture); + if (paletteBytes <= 0) { + continue; + } + if ((size_t)texture->offset + (size_t)paletteBytes > (size_t)textureList->size) { + continue; + } + // TLUT entries are big-endian RGBA5551, matching what the RDP (and Fast3D's + // CI4 importer) reads out of DRAM. + u8* palette = pixelBase + texture->offset; + for (s32 paletteEntry = 0; paletteEntry < paletteBytes / 2; paletteEntry++) { + const u16 packedColor = (u16)((palette[paletteEntry * 2] << 8) | palette[paletteEntry * 2 + 1]); + const u8 red = (u8)(((packedColor >> 11) & 0x1F) * 255 / 31); + const u8 green = (u8)(((packedColor >> 6) & 0x1F) * 255 / 31); + const u8 blue = (u8)(((packedColor >> 1) & 0x1F) * 255 / 31); + if (!PassesFilter(channel, red, green, blue)) { + continue; + } + u8 newRed, newGreen, newBlue; + Recolor(channel, colors.channel[channelIndex], red, green, blue, &newRed, &newGreen, &newBlue); + const u16 recolored = (u16)(((newRed * 31 / 255) << 11) | ((newGreen * 31 / 255) << 6) | + ((newBlue * 31 / 255) << 1) | (packedColor & 1)); + palette[paletteEntry * 2] = (u8)(recolored >> 8); + palette[paletteEntry * 2 + 1] = (u8)(recolored & 0xFF); + } + } + } +} + +void RecolorVertices(void* vertexBlob, const BKPlayerColorSet& colors) { + BKVertexList* vertexList = (BKVertexList*)vertexBlob; + Vtx* vertices = vertexList->vertices; + const s32 vertexCount = vertexList->count; + + for (s32 vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) { + Vtx_t& vertex = vertices[vertexIndex].v; + const u32 packedColor = ((u32)vertex.cn[0] << 16) | ((u32)vertex.cn[1] << 8) | (u32)vertex.cn[2]; + for (int channelIndex = 0; channelIndex < BK_COLOR_CHANNEL_COUNT; channelIndex++) { + if (!colors.channel[channelIndex].enabled) { + continue; + } + const VertexTable& vertexColors = kVertexTables[channelIndex]; + bool matched = false; + for (s32 entry = 0; entry < vertexColors.count; entry++) { + if (vertexColors.color[entry] == packedColor) { + matched = true; + break; + } + } + if (!matched) { + continue; + } + u8 newRed, newGreen, newBlue; + Recolor(kChannels[channelIndex], colors.channel[channelIndex], vertex.cn[0], vertex.cn[1], vertex.cn[2], + &newRed, &newGreen, &newBlue); + vertex.cn[0] = newRed; + vertex.cn[1] = newGreen; + vertex.cn[2] = newBlue; + break; + } + } +} + +Variant* BuildVariant(u32 ownerKey, s32 modelId, BKModelBin* modelBin, const BKPlayerColorSet& colors) { + const TextureTable* textureTable = TexturesForModel(modelId); + if (textureTable == nullptr || modelBin == nullptr || modelBin->texture_list_offset == 0 || + modelBin->vtx_list_offset == 0) { + return nullptr; + } + + Variant& variant = sVariants[VariantKey(ownerKey, modelId)]; + const u64 colorHash = HashColors(colors); + if (variant.sourceBin == modelBin && variant.colorHash == colorHash && variant.textures != nullptr) { + return &variant; + } + + const BKTextureList* sourceTextures = modelbin_getTextureList(modelBin); + const BKVertexList* sourceVertices = modelbin_getVtxList(modelBin); + if (sourceTextures == nullptr || sourceVertices == nullptr) { + return nullptr; + } + + const size_t textureBytes = + sizeof(BKTextureList) + (size_t)sourceTextures->count * sizeof(BKTextureInfo) + (size_t)sourceTextures->size; + const size_t vertexBytes = sizeof(BKVertexList) + (size_t)sourceVertices->count * sizeof(Vtx); + + void* textures = PaletteArenaTake(textureBytes); + if (textures == nullptr) { + return nullptr; + } + std::memcpy(textures, sourceTextures, textureBytes); + + variant.vertices = std::make_unique((vertexBytes + 7) / 8); + std::memcpy(variant.vertices.get(), sourceVertices, vertexBytes); + + variant.sourceBin = modelBin; + variant.colorHash = colorHash; + variant.textures = textures; + + RecolorTextures(variant.textures, textureTable, colors); + RecolorVertices(variant.vertices.get(), colors); + return &variant; +} + +const char* kChannelCVars[] = { + CVAR_REMOTE_ANCHOR("Colors.BanjoFur"), CVAR_REMOTE_ANCHOR("Colors.BanjoShorts"), + CVAR_REMOTE_ANCHOR("Colors.KazooieFeathers"), CVAR_REMOTE_ANCHOR("Colors.KazooieBeak"), + CVAR_REMOTE_ANCHOR("Colors.BanjoBackpack"), CVAR_REMOTE_ANCHOR("Colors.BanjoSkin"), +}; + +#define CHANNEL_TABLE_COMPLETE(table) \ + static_assert(sizeof(table) / sizeof((table)[0]) == BK_COLOR_CHANNEL_COUNT, #table) +CHANNEL_TABLE_COMPLETE(kChannelCVars); +CHANNEL_TABLE_COMPLETE(kChannels); +CHANNEL_TABLE_COMPLETE(kVertexTables); +CHANNEL_TABLE_COMPLETE(kLowTextures); +CHANNEL_TABLE_COMPLETE(kHighTextures); +#undef CHANNEL_TABLE_COMPLETE + +} // namespace + +extern "C" { + +const char* PlayerColors_getChannelCVar(s32 channel) { + if (channel < 0 || channel >= BK_COLOR_CHANNEL_COUNT) { + return nullptr; + } + return kChannelCVars[channel]; +} + +void PlayerColors_getVanilla(s32 channel, u8* red, u8* green, u8* blue) { + if (channel < 0 || channel >= BK_COLOR_CHANNEL_COUNT) { + return; + } + *red = kChannels[channel].vanilla[0]; + *green = kChannels[channel].vanilla[1]; + *blue = kChannels[channel].vanilla[2]; +} + +void PlayerColors_getLocal(BKPlayerColorSet* out) { + if (out == nullptr) { + return; + } + for (int channelIndex = 0; channelIndex < BK_COLOR_CHANNEL_COUNT; channelIndex++) { + const ChannelInfo& channel = kChannels[channelIndex]; + const std::string valueCVar = std::string(kChannelCVars[channelIndex]) + ".Value"; + const std::string enabledCVar = std::string(kChannelCVars[channelIndex]) + ".Enabled"; + const Color_RGBA8 vanilla = { channel.vanilla[0], channel.vanilla[1], channel.vanilla[2], 255 }; + const Color_RGBA8 chosen = CVarGetColor(valueCVar.c_str(), vanilla); + out->channel[channelIndex].enabled = CVarGetInteger(enabledCVar.c_str(), 0) ? 1 : 0; + out->channel[channelIndex].r = chosen.r; + out->channel[channelIndex].g = chosen.g; + out->channel[channelIndex].b = chosen.b; + } +} + +void PlayerColors_applyForDraw(u32 ownerKey, s32 modelId, void* modelBin, const BKPlayerColorSet* colors) { + if (!port_anchor_isConnected()) { + return; + } + if (colors == nullptr || modelBin == nullptr || !AnyEnabled(*colors)) { + return; + } + Variant* variant = BuildVariant(ownerKey, modelId, (BKModelBin*)modelBin, *colors); + if (variant == nullptr) { + return; + } + modelRender_setTextureList((BKTextureList*)variant->textures); + modelRender_setVertexList((BKVertexList*)variant->vertices.get()); +} + +void PlayerColors_applyLocalForDraw(s32 modelId, void* modelBin) { + if (TexturesForModel(modelId) == nullptr) { + return; + } + BKPlayerColorSet colors; + PlayerColors_getLocal(&colors); + PlayerColors_applyForDraw(BK_COLORS_LOCAL_OWNER, modelId, modelBin, &colors); +} + +void PlayerColors_forgetOwner(u32 ownerKey) { + for (auto entry = sVariants.begin(); entry != sVariants.end();) { + entry = ((u32)(entry->first >> 32) == ownerKey) ? sVariants.erase(entry) : std::next(entry); + } +} + +void PlayerColors_reset(void) { + sVariants.clear(); +} + +} // extern "C" diff --git a/src/port/Enhancements/Cosmetics/PlayerColors.h b/src/port/Enhancements/Cosmetics/PlayerColors.h new file mode 100644 index 000000000..c7aae365c --- /dev/null +++ b/src/port/Enhancements/Cosmetics/PlayerColors.h @@ -0,0 +1,43 @@ +#ifndef PORT_ENHANCEMENTS_COSMETICS_PLAYERCOLORS_H +#define PORT_ENHANCEMENTS_COSMETICS_PLAYERCOLORS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + BK_COLOR_BANJO_FUR = 0, + BK_COLOR_BANJO_SHORTS, + BK_COLOR_KAZOOIE_FEATHERS, + BK_COLOR_KAZOOIE_BEAK, + BK_COLOR_BANJO_BACKPACK, + BK_COLOR_BANJO_SKIN, + BK_COLOR_CHANNEL_COUNT +} BKColorChannel; + +typedef struct { + u8 enabled; + u8 r, g, b; +} BKColorChannelSetting; + +typedef struct { + BKColorChannelSetting channel[BK_COLOR_CHANNEL_COUNT]; +} BKPlayerColorSet; + +#define BK_COLORS_LOCAL_OWNER 0u + +void PlayerColors_getVanilla(s32 channel, u8* red, u8* green, u8* blue); +const char* PlayerColors_getChannelCVar(s32 channel); +void PlayerColors_applyForDraw(u32 ownerKey, s32 modelId, void* modelBin, const BKPlayerColorSet* colors); +void PlayerColors_applyLocalForDraw(s32 modelId, void* modelBin); +void PlayerColors_getLocal(BKPlayerColorSet* out); +void PlayerColors_forgetOwner(u32 ownerKey); +void PlayerColors_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PORT_ENHANCEMENTS_COSMETICS_PLAYERCOLORS_H */ diff --git a/src/port/Game.cpp b/src/port/Game.cpp index f77a023b9..bc46b015d 100644 --- a/src/port/Game.cpp +++ b/src/port/Game.cpp @@ -21,6 +21,7 @@ #include "DevTools/ThreadWatchdog.h" #include "GameStatus.h" #include "Interpolation/FrameInterpolation.h" +#include "Nametag/Nametag.h" #include "Network/Anchor/Anchor.h" #include "OS/OS.h" #include "Patches/Patches.h" @@ -107,6 +108,7 @@ extern "C" void port_thread5_onSubmit(void* taskData) { FrameInterpolation_GetRecordingPair(&pair.prev, &pair.curr, &pair.should); FrameInterpolation_ClaimPair(pair.prev, pair.curr); FrameInterpolation_StopRecord(); + Nametag::SubmitFrame(task->data_ptr); std::lock_guard lock(sInterpMutex); pair.serial = ++sInterpSerial; auto [it, inserted] = sTaskInterp.emplace(task->data_ptr, pair); @@ -141,6 +143,7 @@ void RenderTask(void* dlStart) { } } FrameInterpolation_BeginRenderPass(pair.prev, pair.curr, pair.should); + Nametag::BeginRenderPass(dlStart, pair.should); GameEngine::ProcessGfxCommands((Gfx*)dlStart); FrameInterpolation_ReleasePair(pair.prev, pair.curr); } diff --git a/src/port/Nametag/Nametag.cpp b/src/port/Nametag/Nametag.cpp index 116777e54..3c0a52518 100644 --- a/src/port/Nametag/Nametag.cpp +++ b/src/port/Nametag/Nametag.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -29,16 +31,61 @@ constexpr float kBorderThickness = 1.5f; constexpr float kGameAspect = 4.0f / 3.0f; constexpr float kLowResAspect = 320.0f / 240.0f; +// Distance over which a tag fades out as it reaches the edge of its range. +constexpr float kFadeBand = 500.0f; +constexpr float kMaxFadeShare = 0.25f; + struct Entry { - float pos[3]; + uint32_t id; + float screen[2]; + bool onScreen; + float alpha; std::string text; }; -std::vector sQueue; +ImU32 WithAlpha(ImU32 color, float alpha) { + const float scaled = (float)((color >> IM_COL32_A_SHIFT) & 0xFF) * alpha; + const ImU32 value = (ImU32)(scaled < 0.0f ? 0.0f : (scaled > 255.0f ? 255.0f : scaled)); + return (color & ~IM_COL32_A_MASK) | (value << IM_COL32_A_SHIFT); +} + +// Game thread only. +std::vector sRecording; +bool sRecordingStale = false; + +void RetireRecording() { + sRecording.clear(); +} + +struct FrameTags { + uint64_t serial; + std::vector entries; +}; +std::mutex sHandoffMutex; +std::unordered_map sHandoff; +uint64_t sHandoffSerial = 0; +constexpr uint64_t kHandoffStaleAfter = 8; + +// Render thread only. +std::vector sRenderCurr; +std::vector sRenderPrev; +bool sRenderInterpolate = false; +float sSubframeBlend = 1.0f; + Nametag::ProjectFn sProject = nullptr; +Nametag::DistanceFn sDistance = nullptr; const int* sFbWidth = nullptr; const int* sFbHeight = nullptr; +const Entry* FindPrevious(uint32_t id) { + for (const auto& entry : sRenderPrev) { + if (entry.id == id) { + return &entry; + } + } + return nullptr; +} + // Mirrors LUS Gui::GetIntegerScaleFactor; mGameWindowViewport lags one // frame, matching DrawGame's own read order. int16_t ComputeIntegerScaleFactor(const std::shared_ptr& interp) { @@ -174,19 +221,28 @@ class NametagOverlay : public Ship::GuiWindow { const float scaleY = contentH / static_cast(*sFbHeight); ImDrawList* drawList = ImGui::GetForegroundDrawList(); - for (const auto& entry : sQueue) { - float pos[3] = { entry.pos[0], entry.pos[1], entry.pos[2] }; - float screen[2]; - if (sProject(pos, screen)) { - ImVec2 textSize = ImGui::CalcTextSize(entry.text.c_str()); - ImVec2 anchor(originX + screen[0] * scaleX, originY + screen[1] * scaleY); - ImVec2 textTL(anchor.x - textSize.x * 0.5f, anchor.y - textSize.y * 0.5f); - ImVec2 boxTL(textTL.x - kPaddingX, textTL.y - kPaddingY); - ImVec2 boxBR(textTL.x + textSize.x + kPaddingX, textTL.y + textSize.y + kPaddingY); - drawList->AddRectFilled(boxTL, boxBR, kBgColor, kCornerRadius); - drawList->AddRect(boxTL, boxBR, kBorderColor, kCornerRadius, 0, kBorderThickness); - drawList->AddText(textTL, kTextColor, entry.text.c_str()); + for (const auto& entry : sRenderCurr) { + if (!entry.onScreen) { + continue; } + float screenX = entry.screen[0]; + float screenY = entry.screen[1]; + float alpha = entry.alpha; + const Entry* previous = sRenderInterpolate ? FindPrevious(entry.id) : nullptr; + if (previous != nullptr && previous->onScreen) { + screenX = previous->screen[0] + (screenX - previous->screen[0]) * sSubframeBlend; + screenY = previous->screen[1] + (screenY - previous->screen[1]) * sSubframeBlend; + alpha = previous->alpha + (alpha - previous->alpha) * sSubframeBlend; + } + + ImVec2 textSize = ImGui::CalcTextSize(entry.text.c_str()); + ImVec2 anchor(originX + screenX * scaleX, originY + screenY * scaleY); + ImVec2 textTL(anchor.x - textSize.x * 0.5f, anchor.y - textSize.y * 0.5f); + ImVec2 boxTL(textTL.x - kPaddingX, textTL.y - kPaddingY); + ImVec2 boxBR(textTL.x + textSize.x + kPaddingX, textTL.y + textSize.y + kPaddingY); + drawList->AddRectFilled(boxTL, boxBR, WithAlpha(kBgColor, alpha), kCornerRadius); + drawList->AddRect(boxTL, boxBR, WithAlpha(kBorderColor, alpha), kCornerRadius, 0, kBorderThickness); + drawList->AddText(textTL, WithAlpha(kTextColor, alpha), entry.text.c_str()); } } }; @@ -199,6 +255,10 @@ void SetProjectFn(ProjectFn fn) { sProject = fn; } +void SetDistanceFn(DistanceFn fn) { + sDistance = fn; +} + void SetNativeFramebufferSize(const int* width, const int* height) { sFbWidth = width; sFbHeight = height; @@ -211,20 +271,88 @@ void RegisterOverlay() { overlay->Show(); } -void Clear() { - sQueue.clear(); +void BeginDraw() { + if (sRecordingStale) { + RetireRecording(); + } + sRecordingStale = true; } -void Push(float x, float y, float z, const char* label) { - if (label == nullptr) { +void Push(uint32_t id, float x, float y, float z, const char* label, float alpha) { + if (label == nullptr || alpha <= 0.0f || sProject == nullptr) { return; } + if (sRecordingStale) { + RetireRecording(); + sRecordingStale = false; + } Entry entry; - entry.pos[0] = x; - entry.pos[1] = y; - entry.pos[2] = z; + entry.id = id; + float pos[3] = { x, y, z }; + entry.onScreen = sProject(pos, entry.screen); + entry.alpha = alpha > 1.0f ? 1.0f : alpha; entry.text = label; - sQueue.push_back(std::move(entry)); + sRecording.push_back(std::move(entry)); +} + +// Game thread. A list is submitted while its tick is still recording, so the +// tags travel with the task the same way the interpolation pair does. +void SubmitFrame(const void* key) { + std::lock_guard lock(sHandoffMutex); + const uint64_t serial = ++sHandoffSerial; + auto [it, inserted] = sHandoff.emplace(key, FrameTags{ serial, sRecording }); + if (!inserted) { + it->second.serial = serial; + it->second.entries = sRecording; + } + + // Anything still here after a full trip round the ring will never be picked + // up by a render pass, so drop it rather than grow the map forever. + for (auto stale = sHandoff.begin(); stale != sHandoff.end();) { + if (stale->second.serial + kHandoffStaleAfter < serial) { + stale = sHandoff.erase(stale); + } else { + ++stale; + } + } +} + +// Render thread. Adopt the tags submitted with this list; what the previous +// pass drew stays behind as the interpolation source. +void BeginRenderPass(const void* key, bool interpolate) { + std::vector tags; + { + std::lock_guard lock(sHandoffMutex); + auto it = sHandoff.find(key); + if (it != sHandoff.end()) { + tags = std::move(it->second.entries); + sHandoff.erase(it); + } + } + sRenderPrev = std::move(sRenderCurr); + sRenderCurr = std::move(tags); + sRenderInterpolate = interpolate; +} + +float FadeForDistance(float x, float y, float z, float maxDistance) { + if (sDistance == nullptr) { + return 1.0f; + } + float pos[3] = { x, y, z }; + const float distance = sDistance(pos); + if (distance >= maxDistance) { + return 0.0f; + } + const float band = std::min(kFadeBand, maxDistance * kMaxFadeShare); + const float fadeStart = maxDistance - band; + if (distance <= fadeStart || band <= 0.0f) { + return 1.0f; + } + return (maxDistance - distance) / band; +} + +void SetSubframeBlend(float blend) { + sSubframeBlend = blend < 0.0f ? 0.0f : (blend > 1.0f ? 1.0f : blend); } } // namespace Nametag \ No newline at end of file diff --git a/src/port/Nametag/Nametag.h b/src/port/Nametag/Nametag.h index 478fde36a..32a767787 100644 --- a/src/port/Nametag/Nametag.h +++ b/src/port/Nametag/Nametag.h @@ -10,12 +10,17 @@ namespace Nametag { // World -> native-framebuffer-pixel projector. Writes (screen[0], screen[1]) // and returns true if the point is in front of the camera. using ProjectFn = bool (*)(float pos[3], float* screen); +using DistanceFn = float (*)(float pos[3]); void SetProjectFn(ProjectFn fn); +void SetDistanceFn(DistanceFn fn); void SetNativeFramebufferSize(const int* width, const int* height); void RegisterOverlay(); - -void Clear(); -void Push(float x, float y, float z, const char* label); +void BeginDraw(); +void Push(uint32_t id, float x, float y, float z, const char* label, float alpha = 1.0f); +float FadeForDistance(float x, float y, float z, float maxDistance); +void SetSubframeBlend(float blend); +void SubmitFrame(const void* key); +void BeginRenderPass(const void* key, bool interpolate); } // namespace Nametag \ No newline at end of file diff --git a/src/port/Nametag/NametagBindings.cpp b/src/port/Nametag/NametagBindings.cpp index 88220be18..c3fb481ce 100644 --- a/src/port/Nametag/NametagBindings.cpp +++ b/src/port/Nametag/NametagBindings.cpp @@ -1,16 +1,13 @@ -// BK-specific bindings for the portable Nametag overlay: projection, FB -// dimensions, tick-event listeners, and the asset-id -> label table. +// BK-specific bindings for the portable Nametag overlay: projection, distance, +// FB dimensions, and the per-draw tick listener. #include #include "Nametag.h" #include "port/Enhancements/Events/Hooks/Events.h" #include "port/ShipInit.hpp" -#include "port/UI/cvar_prefixes.h" -#include "port/ObjectExtension/ObjectExtension.h" extern "C" { -#include "enums.h" #include "core1/viewport.h" extern int gFramebufferWidth; extern int gFramebufferHeight; @@ -18,41 +15,9 @@ extern int gFramebufferHeight; namespace { -struct NametagTableEntry { - int32_t assetId; - const char* label; - float yOffset; -}; - -// Asset-id -> nametag lookup. Shared across the actor / prop / sprite-prop paths. -constexpr NametagTableEntry sNametagTable[] = { - { ASSET_350_MODEL_TERMITE, "Termite", 150.0f }, { ASSET_3C5_MODEL_GRUBLIN, "Grublin", 150.0f }, - { ASSET_353_MODEL_BIGBUTT, "BigButt", 150.0f }, { ASSET_3C0_MODEL_JINJO_BLUE, "Blue Jinjo", 150.0f }, - { ASSET_3C2_MODEL_JINJO_GREEN, "Green Jinjo", 150.0f }, { ASSET_3BC_MODEL_JINJO_ORANGE, "Orange Jinjo", 150.0f }, - { ASSET_3C1_MODEL_JINJO_PINK, "Pink Jinjo", 150.0f }, { ASSET_3BB_MODEL_JINJO_YELLOW, "Yellow Jinjo", 150.0f }, - { ASSET_6D6_SPRITE_MUSIC_NOTE, "Note", 80.0f }, { ASSET_41A_SPRITE_MUMBO_TOKEN, "Mumbo Token", 80.0f }, -}; - -const NametagTableEntry* LookupNametag(int32_t assetId) { - for (const auto& entry : sNametagTable) { - if (entry.assetId == assetId) { - return &entry; - } - } - return nullptr; -} - -static bool inRange(float x, float y, float z) { - float pos[3] = { x, y, z }; - return viewport_getDistance(pos) < CVarGetFloat(CVAR_DEVELOPER_TOOLS("NametagDist"), 3000.0f); -} - -static bool inRange(int16_t pos[3]) { - return inRange((float)pos[0], (float)pos[1], (float)pos[2]); -} - void InitNametagBindings() { Nametag::SetProjectFn(&viewport_func_8024E030); + Nametag::SetDistanceFn(&viewport_getDistance); Nametag::SetNativeFramebufferSize(&gFramebufferWidth, &gFramebufferHeight); Nametag::RegisterOverlay(); } @@ -60,6 +25,9 @@ void InitNametagBindings() { } // namespace static RegisterShipInitFunc initNametagBindings(InitNametagBindings, { "BOOT" }); + +// Tags are queued during the world draw, so the queue turns over on OnPlayerDraw rather than +// on the game tick. Ordering against the producers on this event does not matter. static RegisterShipInitFunc init([]() { - REGISTER_LISTENER(GameFrameUpdate, EVENT_PRIORITY_HIGH, [](IEvent* event) { Nametag::Clear(); }); -}); \ No newline at end of file + REGISTER_LISTENER(OnPlayerDraw, EVENT_PRIORITY_HIGH, [](IEvent* event) { Nametag::BeginDraw(); }); +}); diff --git a/src/port/Network/Anchor/Anchor.cpp b/src/port/Network/Anchor/Anchor.cpp index 545ac5910..0e9fd79cb 100644 --- a/src/port/Network/Anchor/Anchor.cpp +++ b/src/port/Network/Anchor/Anchor.cpp @@ -47,6 +47,7 @@ void Anchor::Disable() { } } clients.clear(); + PlayerColors_reset(); RefreshClientActors(); } @@ -301,13 +302,32 @@ void Anchor::SetDummyPlayerClientId(const Actor* actor, uint32_t clientId) { ObjectExtension::GetInstance().Set(actor, DummyPlayerClientId{ clientId }); } +// Roughly the top of Banjo's head. +static constexpr f32 kNametagHeight = 155.0f; + +// The menu exposes the nametag range as a multiplier of this, the same way Extended Draw +// Distance scales off its own base. +static constexpr f32 kNametagRangeUnit = 3000.0f; + void Anchor::DrawDummies(OnPlayerDraw* event) { if (!isConnected) return; + const bool showNametags = CVarGetInteger(CVAR_REMOTE_ANCHOR("Nametags"), 1) != 0; + const f32 nametagRange = kNametagRangeUnit * CVarGetFloat(CVAR_REMOTE_ANCHOR("NametagScale"), 1.0f); for (const auto& [id, dummy] : dummies) { FrameInterpolation_RecordOpenChild(clients[id].name.c_str(), 0); dummy->Draw(event->gfx, event->mtx, event->vtx); FrameInterpolation_RecordCloseChild(); + + // A nameless client would otherwise get an empty tag box floating over them. + const std::string label = showNametags ? GetNametagLabel(id) : std::string(); + if (dummy->dummy_isVisible() && !label.empty()) { + f32 position[3]; + dummy->dummy_getPosition(position); + position[1] += kNametagHeight; + const f32 fade = Nametag::FadeForDistance(position[0], position[1], position[2], nametagRange); + Nametag::Push(id, position[0], position[1], position[2], label.c_str(), fade); + } } } @@ -416,6 +436,19 @@ void Anchor::RegisterDummy(DummyPlayer* dummy, uint32_t clientID) { dummies.emplace(clientID, dummy); } +// Pushes a client's chosen model colours onto their stand-in so the next draw picks them up. +void Anchor::ApplyClientCosmetics(uint32_t clientId) { + if (!clients.contains(clientId)) { + return; + } + AnchorClient& client = clients[clientId]; + if (client.dummy == nullptr) { + return; + } + client.dummy->dummy_setOwner(clientId); + client.dummy->dummy_setColors(client.colors); +} + void Anchor::EvaluateDummyForClient(uint32_t clientId) { if (!clients.contains(clientId)) return; @@ -456,6 +489,21 @@ bool Anchor::ShouldShowNotifications() { return CVarGetInteger(CVAR_REMOTE_ANCHOR("Notifications"), 1) != 0; } +// Public global room shows player number instead of custom name +std::string Anchor::GetNametagLabel(uint32_t clientId) { + auto it = clients.find(clientId); + if (it == clients.end()) { + return ""; + } + if (!IsGlobalRoom()) { + return it->second.name; + } + if (it->second.joinOrder == 0) { + return "Player"; + } + return "Player " + std::to_string(it->second.joinOrder); +} + std::string Anchor::GetClientName(uint32_t clientId) { auto it = clients.find(clientId); if (it != clients.end() && !it->second.name.empty()) { diff --git a/src/port/Network/Anchor/Anchor.h b/src/port/Network/Anchor/Anchor.h index 2e42709aa..7f4fa1359 100644 --- a/src/port/Network/Anchor/Anchor.h +++ b/src/port/Network/Anchor/Anchor.h @@ -20,11 +20,12 @@ extern "C" { typedef struct { uint32_t clientId; std::string name; - // Color_RGB8 color; + BKPlayerColorSet colors; std::string clientVersion; std::string teamId; bool online; bool self; + uint32_t joinOrder; uint32_t seed; bool isSaveLoaded; bool isGameComplete; @@ -79,6 +80,7 @@ class Anchor : public Network { void RemoveDummy(uint32_t clientId); void EvaluateDummyForClient(uint32_t clientId); + void ApplyClientCosmetics(uint32_t clientId); void HandlePacket_AllClientState(nlohmann::json& payload); void HandlePacket_AuthorityState(nlohmann::json& payload); @@ -218,6 +220,7 @@ class Anchor : public Network { bool ShouldShowNotifications(); std::string GetClientName(uint32_t clientId); + std::string GetNametagLabel(uint32_t clientId); void SendPacket_AuthorityState(u8 activity, bool claimed); void SendPacket_ClearTeamState(std::string teamId); diff --git a/src/port/Network/Anchor/DummyPlayer.cpp b/src/port/Network/Anchor/DummyPlayer.cpp index f04798ddb..88fe6e033 100644 --- a/src/port/Network/Anchor/DummyPlayer.cpp +++ b/src/port/Network/Anchor/DummyPlayer.cpp @@ -199,6 +199,8 @@ void DummyPlayer::Draw(Gfx** gfx, Mtx** mtx, Vtx** vtx) { // modelRender_preDraw((GenFunction_1)_dummy_preDraw, 0); modelRender_setRefPoints(dummy_D_80363780); modelRender_setDepthMode(MODEL_RENDER_DEPTH_FULL); + // Each dummy draws the shared model bin through its own recolored palettes. + PlayerColors_applyForDraw(dummyOwnerId, dummy_getModelId(), dummyBin, &dummyColors); if (dummy_D_8037C150.unk0) { dummy_D_8037C150.unk0 = 0; modelRender_draw(gfx, mtx, dummy_D_8037C150.unk4, rotation, dummyScale, sp38, dummyBin); diff --git a/src/port/Network/Anchor/DummyPlayer.h b/src/port/Network/Anchor/DummyPlayer.h index 825e36091..c472559f6 100644 --- a/src/port/Network/Anchor/DummyPlayer.h +++ b/src/port/Network/Anchor/DummyPlayer.h @@ -2,6 +2,7 @@ #include "functions.h" #include "core2/ba/model.h" #include "variables.h" +#include "port/Enhancements/Cosmetics/PlayerColors.h" class DummyPlayer { public: @@ -81,6 +82,13 @@ class DummyPlayer { return dummyMarker; } void dummy_despawnActor(void); + // Cosmetics: which Anchor client this stand-in belongs to, and the model colors they chose. + void dummy_setOwner(uint32_t clientId) { + dummyOwnerId = clientId; + } + void dummy_setColors(const BKPlayerColorSet& colors) { + dummyColors = colors; + } private: uint32_t PlayerID; @@ -138,4 +146,6 @@ class DummyPlayer { f32 dummy_banjoRightEye; Transformation dummy_transformation; s32 dummyBottlesBonus = 0; + uint32_t dummyOwnerId = 0; + BKPlayerColorSet dummyColors = {}; }; \ No newline at end of file diff --git a/src/port/Network/Anchor/JsonConversions.hpp b/src/port/Network/Anchor/JsonConversions.hpp index 243b8b25e..ada991d3b 100644 --- a/src/port/Network/Anchor/JsonConversions.hpp +++ b/src/port/Network/Anchor/JsonConversions.hpp @@ -22,9 +22,42 @@ inline void to_json(json& j, const Color_RGB8& color) { j = json{ { "r", color.r }, { "g", color.g }, { "b", color.b } }; } +// Model colors travel as a fixed-length array, one entry per BKColorChannel. Clients that +// predate the feature simply omit the key and everyone renders them with vanilla colors. +inline void from_json(const json& j, BKPlayerColorSet& colors) { + colors = BKPlayerColorSet{}; + if (!j.is_array()) { + return; + } + for (size_t i = 0; i < j.size() && i < (size_t)BK_COLOR_CHANNEL_COUNT; i++) { + const json& entry = j[i]; + if (!entry.is_object()) { + continue; + } + colors.channel[i].enabled = entry.value("on", false) ? 1 : 0; + colors.channel[i].r = entry.value("r", (u8)0); + colors.channel[i].g = entry.value("g", (u8)0); + colors.channel[i].b = entry.value("b", (u8)0); + } +} + +inline void to_json(json& j, const BKPlayerColorSet& colors) { + j = json::array(); + for (int i = 0; i < BK_COLOR_CHANNEL_COUNT; i++) { + j.push_back(json{ { "on", colors.channel[i].enabled != 0 }, + { "r", colors.channel[i].r }, + { "g", colors.channel[i].g }, + { "b", colors.channel[i].b } }); + } +} + inline void from_json(const json& j, AnchorClient& client) { client.clientId = j.value("clientId", (u32)0); client.name = j.value("name", "???"); + client.colors = BKPlayerColorSet{}; + if (j.contains("colors")) { + from_json(j.at("colors"), client.colors); + } client.clientVersion = j.value("clientVersion", "???"); client.teamId = j.value("teamId", "default"); client.online = j.value("online", false); diff --git a/src/port/Network/Anchor/Menu.cpp b/src/port/Network/Anchor/Menu.cpp index e5d543fb2..f9080c128 100644 --- a/src/port/Network/Anchor/Menu.cpp +++ b/src/port/Network/Anchor/Menu.cpp @@ -1,4 +1,5 @@ #include "Anchor.h" +#include #include #include "port/UI/LighthouseGui.hpp" #include "port/UI/LighthouseMenu.h" @@ -16,6 +17,68 @@ static const char* pvpModes[3] = { "Off", "On", "On + Friendly Fire" }; static std::vector teleportModes = { "None", "Team Only", "All" }; static std::vector showLocationsModes = { "None", "Team Only", "All" }; +static const char* playerColorLabels[BK_COLOR_CHANNEL_COUNT] = { + "Banjo's fur", "Banjo's shorts", "Kazooie's feathers", "Kazooie's beak & legs", "Banjo's backpack", "Banjo's skin", +}; + +// Channel order is the wire order; this is the order the rows are listed in. +static const int playerColorOrder[BK_COLOR_CHANNEL_COUNT] = { + BK_COLOR_BANJO_FUR, BK_COLOR_BANJO_SKIN, BK_COLOR_BANJO_SHORTS, + BK_COLOR_BANJO_BACKPACK, BK_COLOR_KAZOOIE_FEATHERS, BK_COLOR_KAZOOIE_BEAK, +}; + +// Per-part recoloring of the player model. Any mix of channels can be enabled; each one +// re-tints only the palette entries and vertex colors belonging to that part, so shading +// is preserved. The selection is part of client state, so everyone in the room sees it. +static void AnchorPlayerColorSection(Anchor* anchor) { + UIWidgets::PushStyleHeader(THEME_COLOR); + if (!ImGui::CollapsingHeader("Player Colors")) { + UIWidgets::PopStyleHeader(); + return; + } + + ImGui::TextWrapped("Tick a part and pick a color to recolor Banjo & Kazooie. Other players in your " + "room see your choices. Transformations keep their own colors."); + ImGui::Spacing(); + + for (int row = 0; row < BK_COLOR_CHANNEL_COUNT; row++) { + const int i = playerColorOrder[row]; + const char* baseCVar = PlayerColors_getChannelCVar(i); + if (baseCVar == nullptr) { + continue; + } + u8 r, g, b; + PlayerColors_getVanilla(i, &r, &g, &b); + const Color_RGBA8 vanilla = { r, g, b, 255 }; + const std::string enabledCVar = std::string(baseCVar) + ".Enabled"; + const std::string pickerLabel = std::string("##") + baseCVar; + + ImGui::PushID(i); + UIWidgets::CVarColorPicker(pickerLabel.c_str(), baseCVar, vanilla, false, + UIWidgets::ColorPickerResetButton | UIWidgets::ColorPickerRandomButton, THEME_COLOR); + ImGui::SameLine(); + UIWidgets::CVarCheckbox(playerColorLabels[i], enabledCVar.c_str(), + UIWidgets::CheckboxOptions().DefaultValue(false).Color(THEME_COLOR)); + ImGui::PopID(); + } + + // Compare the resulting set rather than the widgets' return values: Reset and Random + // write the CVar without reporting a change. + static BKPlayerColorSet lastSent = {}; + static bool haveLastSent = false; + BKPlayerColorSet current; + PlayerColors_getLocal(¤t); + if (!haveLastSent || std::memcmp(¤t, &lastSent, sizeof(current)) != 0) { + lastSent = current; + haveLastSent = true; + if (anchor->isEnabled) { + anchor->SendPacket_UpdateClientState(); + } + } + + UIWidgets::PopStyleHeader(); +} + void AnchorMainMenu(WidgetInfo& info) { auto anchor = Anchor::GetInstance(); @@ -48,10 +111,7 @@ void AnchorMainMenu(WidgetInfo& info) { } UIWidgets::PopStyleInput(); - ImGui::Text("Name & Color"); - static Color_RGBA8 defaultColor = { 100, 255, 100, 255 }; - UIWidgets::CVarColorPicker("##Color", CVAR_REMOTE_ANCHOR("Color"), defaultColor); - ImGui::SameLine(); + ImGui::Text("Name"); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); if (UIWidgets::InputString("##Name", &anchorName, UIWidgets::InputOptions().Color(THEME_COLOR))) { CVarSetString(CVAR_REMOTE_ANCHOR("Name"), anchorName.c_str()); @@ -120,6 +180,31 @@ void AnchorMainMenu(WidgetInfo& info) { ImGui::EndDisabled(); ImGui::Spacing(); + AnchorPlayerColorSection(anchor); + + ImGui::Spacing(); + + UIWidgets::CVarCheckbox("Show Player Nametags", CVAR_REMOTE_ANCHOR("Nametags"), + UIWidgets::CheckboxOptions() + .DefaultValue(true) + .Color(THEME_COLOR) + .Tooltip("Draw each other player's name above their character.")); + + if (CVarGetInteger(CVAR_REMOTE_ANCHOR("Nametags"), 1)) { + UIWidgets::CVarSliderFloat("Nametag Distance", CVAR_REMOTE_ANCHOR("NametagScale"), + UIWidgets::FloatSliderOptions() + .Min(0.5f) + .Max(6.0f) + .Step(0.5f) + .DefaultValue(1.0f) + .ShowButtons(true) + .Format("%.1fx") + .Color(THEME_COLOR) + .Tooltip("Multiplies how far away another player's nametag stays visible.")); + } + + ImGui::Spacing(); + UIWidgets::CVarCheckbox( "Show Teammate Notifications", CVAR_REMOTE_ANCHOR("Notifications"), UIWidgets::CheckboxOptions() diff --git a/src/port/Network/Anchor/Packets/AllClientState.cpp b/src/port/Network/Anchor/Packets/AllClientState.cpp index e793947e3..59d6ee01a 100644 --- a/src/port/Network/Anchor/Packets/AllClientState.cpp +++ b/src/port/Network/Anchor/Packets/AllClientState.cpp @@ -21,8 +21,10 @@ void Anchor::HandlePacket_AllClientState(nlohmann::json& payload) { bool isGlobalRoom = IsGlobalRoom(); std::vector clientsToRemove; + uint32_t joinOrder = 0; // add new clients for (auto& client : newClients) { + client.joinOrder = client.online ? ++joinOrder : 0; if (!client.online && clients.contains(client.clientId)) { clientsToRemove.push_back(client.clientId); } @@ -62,6 +64,9 @@ void Anchor::HandlePacket_AllClientState(nlohmann::json& payload) { clients[client.clientId].isGameComplete = client.isGameComplete; clients[client.clientId].map = client.map; clients[client.clientId].exit = client.exit; + clients[client.clientId].joinOrder = client.joinOrder; + clients[client.clientId].colors = client.colors; + ApplyClientCosmetics(client.clientId); Authority_OnClientStateChanged(client.clientId, client.online, client.map); } @@ -78,6 +83,7 @@ void Anchor::HandlePacket_AllClientState(nlohmann::json& payload) { if (dummies.contains(clientId)) { dummies.erase(clientId); } + PlayerColors_forgetOwner(clientId); if (clients.at(clientId).dummy != nullptr) { clients.at(clientId).dummy->dummy_free(); free(clients.at(clientId).dummy); diff --git a/src/port/Network/Anchor/Packets/UpdateClientState.cpp b/src/port/Network/Anchor/Packets/UpdateClientState.cpp index 43c658b24..12ef0a91b 100644 --- a/src/port/Network/Anchor/Packets/UpdateClientState.cpp +++ b/src/port/Network/Anchor/Packets/UpdateClientState.cpp @@ -26,6 +26,10 @@ nlohmann::json Anchor::PrepClientState() { payload["teamId"] = CVarGetString(CVAR_REMOTE_ANCHOR("TeamId"), "default"); payload["online"] = true; + BKPlayerColorSet colors; + PlayerColors_getLocal(&colors); + to_json(payload["colors"], colors); + if (IsSaveLoaded()) { payload["seed"] = (uint32_t)(IS_RANDO ? RANDO_SEED : 0); payload["isSaveLoaded"] = true; @@ -66,6 +70,8 @@ void Anchor::HandlePacket_UpdateClientState(nlohmann::json& payload) { clients[clientId].isGameComplete = client.isGameComplete; clients[clientId].map = client.map; clients[clientId].exit = client.exit; + clients[clientId].colors = client.colors; + ApplyClientCosmetics(clientId); EvaluateDummyForClient(clientId); Authority_OnClientStateChanged(clientId, client.online, client.map); SweepUnoccupiedLevelState((GameMap)gsworld_getMap()); diff --git a/src/port/UI/LighthouseMenuDevTools.cpp b/src/port/UI/LighthouseMenuDevTools.cpp index e36c7ae71..c72e60bf2 100644 --- a/src/port/UI/LighthouseMenuDevTools.cpp +++ b/src/port/UI/LighthouseMenuDevTools.cpp @@ -72,9 +72,6 @@ void LighthouseMenu::AddMenuDevTools() { .Options(CheckboxOptions().Tooltip( "Enables the Gfx trace mode, which will output information about the Gfx commands being run.")); #endif - AddWidget(path, "Nametag Distance", WIDGET_CVAR_SLIDER_FLOAT) - .CVar(CVAR_DEVELOPER_TOOLS("NametagDist")) - .Options(FloatSliderOptions().DefaultValue(3000.0f).Min(1000.0f).Max(10000.0f).Step(10.0f)); AddWidget(path, "Dump Thread State", WIDGET_BUTTON) .Callback([](WidgetInfo&) { ThreadWatchdog_DumpNow(); }) .Options( diff --git a/src/port/UI/UIWidgets.cpp b/src/port/UI/UIWidgets.cpp index 518e051f8..e41ab88db 100644 --- a/src/port/UI/UIWidgets.cpp +++ b/src/port/UI/UIWidgets.cpp @@ -903,7 +903,8 @@ bool CVarColorPicker(const char* label, const char* cvarName, Color_RGBA8 defaul ImGui::AlignTextToFramePadding(); if (showReset) { ImGui::SameLine(); - std::string uniqueTag = "Reset##" + std::string(label); + // Tag off the CVar, not the label. + std::string uniqueTag = "Reset##" + std::string(cvarName) + ".Reset"; if (UIWidgets::Button(uniqueTag.c_str(), UIWidgets::ButtonOptions({ { .tooltip = "Resets this color to its default value" } }) .Color(themeColor) @@ -920,7 +921,7 @@ bool CVarColorPicker(const char* label, const char* cvarName, Color_RGBA8 defaul } if (showRandom) { ImGui::SameLine(); - std::string uniqueTag = "Random##" + std::string(label); + std::string uniqueTag = "Random##" + std::string(cvarName) + ".Random"; if (UIWidgets::Button(uniqueTag.c_str(), UIWidgets::ButtonOptions({ { .tooltip = "Generates a random color value to use" } }) .Color(themeColor)