Restructure inventory UI, add vendor selling, camera intro on all spawns, and quest log

Split inventory into bags-only (B key) and character screen (C key). Vendor window
auto-opens bags with sell prices on hover and right-click to sell. Add camera intro
pan on all login/spawn/teleport/hearthstone events and idle orbit after 2 minutes.
Add quest log UI, SMSG_MONSTER_MOVE handling, deferred creature spawn queue, and
creature fade-in/movement interpolation for online mode.
This commit is contained in:
Kelsi 2026-02-06 13:47:03 -08:00
parent bb4c2c25f7
commit 71c3d2ea77
21 changed files with 1092 additions and 149 deletions

View file

@ -60,6 +60,7 @@ void CameraController::startIntroPan(float durationSec, float orbitDegrees) {
if (!camera) return;
introActive = true;
introTimer = 0.0f;
idleTimer_ = 0.0f;
introDuration = std::max(0.5f, durationSec);
introStartYaw = facingYaw + orbitDegrees;
introEndYaw = facingYaw;
@ -96,9 +97,24 @@ void CameraController::update(float deltaTime) {
bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL));
bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_SPACE);
// Idle camera: any input resets the timer; timeout triggers a slow orbit pan
bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump;
if (anyInput) {
idleTimer_ = 0.0f;
} else if (!introActive) {
idleTimer_ += deltaTime;
if (idleTimer_ >= IDLE_TIMEOUT) {
idleTimer_ = 0.0f;
startIntroPan(6.0f, 360.0f); // Slow full orbit
idleOrbit_ = true;
}
}
if (introActive) {
if (leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump) {
if (anyInput) {
introActive = false;
idleOrbit_ = false;
idleTimer_ = 0.0f;
} else {
introTimer += deltaTime;
float t = (introDuration > 0.0f) ? std::min(introTimer / introDuration, 1.0f) : 1.0f;
@ -109,7 +125,13 @@ void CameraController::update(float deltaTime) {
camera->setRotation(yaw, pitch);
facingYaw = yaw;
if (t >= 1.0f) {
introActive = false;
if (idleOrbit_) {
// Loop: restart the slow orbit continuously
startIntroPan(6.0f, 360.0f);
idleOrbit_ = true;
} else {
introActive = false;
}
}
}
// Suppress player movement/input during intro.

View file

@ -102,6 +102,7 @@ bool CharacterRenderer::initialize() {
uniform mat4 uLightSpaceMatrix;
uniform int uShadowEnabled;
uniform float uShadowStrength;
uniform float uOpacity;
out vec4 FragColor;
@ -154,8 +155,8 @@ bool CharacterRenderer::initialize() {
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
result = mix(uFogColor, result, fogFactor);
// Force alpha=1 for opaque character rendering (baked NPC textures may have alpha=0)
FragColor = vec4(result, 1.0);
// Apply opacity (for fade-in effects)
FragColor = vec4(result, uOpacity);
}
)";
@ -906,6 +907,35 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId,
}
void CharacterRenderer::update(float deltaTime) {
// Update fade-in opacity
for (auto& [id, inst] : instances) {
if (inst.fadeInDuration > 0.0f && inst.opacity < 1.0f) {
inst.fadeInTime += deltaTime;
inst.opacity = std::min(1.0f, inst.fadeInTime / inst.fadeInDuration);
if (inst.opacity >= 1.0f) {
inst.fadeInDuration = 0.0f;
}
}
}
// Interpolate creature movement
for (auto& [id, inst] : instances) {
if (inst.isMoving) {
inst.moveElapsed += deltaTime;
float t = inst.moveElapsed / inst.moveDuration;
if (t >= 1.0f) {
inst.position = inst.moveEnd;
inst.isMoving = false;
// Return to idle when movement completes
if (inst.currentAnimationId == 4) {
playAnimation(id, 0, true);
}
} else {
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
}
}
}
for (auto& pair : instances) {
updateAnimation(pair.second, deltaTime);
}
@ -1123,6 +1153,8 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
characterShader->use();
characterShader->setUniform("uView", view);
@ -1155,11 +1187,15 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
const auto& gpuModel = models[instance.modelId];
// Skip fully transparent instances
if (instance.opacity <= 0.0f) continue;
// Set model matrix (use override for weapon instances)
glm::mat4 modelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix
: getModelMatrix(instance);
characterShader->setUniform("uModel", modelMat);
characterShader->setUniform("uOpacity", instance.opacity);
// Set bone matrices (upload all at once for performance)
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
@ -1273,6 +1309,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
}
glBindVertexArray(0);
glDisable(GL_BLEND);
glEnable(GL_CULL_FACE); // Restore culling for other renderers
}
@ -1379,6 +1416,55 @@ void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3
}
}
void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) {
auto it = instances.find(instanceId);
if (it == instances.end()) return;
auto& inst = it->second;
if (durationSeconds <= 0.0f) {
// Instant move (stop)
inst.position = destination;
inst.isMoving = false;
// Return to idle animation if currently walking
if (inst.currentAnimationId == 4) {
playAnimation(instanceId, 0, true);
}
return;
}
inst.moveStart = inst.position;
inst.moveEnd = destination;
inst.moveDuration = durationSeconds;
inst.moveElapsed = 0.0f;
inst.isMoving = true;
// Face toward destination (yaw around Z axis since Z is up)
glm::vec3 dir = destination - inst.position;
if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) {
float angle = std::atan2(dir.y, dir.x);
inst.rotation.z = angle;
}
// Play walk animation (ID 4) while moving
if (inst.currentAnimationId == 0) {
playAnimation(instanceId, 4, true);
}
}
const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const {
auto it = models.find(modelId);
if (it == models.end()) return nullptr;
return &it->second.data;
}
void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) {
auto it = instances.find(instanceId);
if (it == instances.end()) return;
it->second.opacity = 0.0f;
it->second.fadeInTime = 0.0f;
it->second.fadeInDuration = durationSeconds;
}
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
auto it = instances.find(instanceId);
if (it != instances.end()) {

View file

@ -391,7 +391,8 @@ uint32_t Renderer::resolveMeleeAnimId() {
return 0.0f;
};
const uint32_t attackCandidates[] = {16, 17, 18, 19, 20, 21};
// Prefer weapon attacks (1H=17, 2H=18) over unarmed (16); 19-21 are other variants
const uint32_t attackCandidates[] = {17, 18, 16, 19, 20, 21};
for (uint32_t id : attackCandidates) {
if (characterRenderer->hasAnimation(characterInstanceId, id)) {
meleeAnimId = id;
@ -1032,6 +1033,113 @@ void Renderer::update(float deltaTime) {
lastUpdateMs = std::chrono::duration<double, std::milli>(updateEnd - updateStart).count();
}
// ============================================================
// Selection Circle
// ============================================================
void Renderer::initSelectionCircle() {
if (selCircleVAO) return;
// Minimal shader: position + uniform MVP + color
const char* vsSrc = R"(
#version 330 core
layout(location = 0) in vec3 aPos;
uniform mat4 uMVP;
void main() {
gl_Position = uMVP * vec4(aPos, 1.0);
}
)";
const char* fsSrc = R"(
#version 330 core
uniform vec3 uColor;
out vec4 FragColor;
void main() {
FragColor = vec4(uColor, 0.6);
}
)";
auto compile = [](GLenum type, const char* src) -> GLuint {
GLuint s = glCreateShader(type);
glShaderSource(s, 1, &src, nullptr);
glCompileShader(s);
return s;
};
GLuint vs = compile(GL_VERTEX_SHADER, vsSrc);
GLuint fs = compile(GL_FRAGMENT_SHADER, fsSrc);
selCircleShader = glCreateProgram();
glAttachShader(selCircleShader, vs);
glAttachShader(selCircleShader, fs);
glLinkProgram(selCircleShader);
glDeleteShader(vs);
glDeleteShader(fs);
// Build ring vertices (two concentric circles forming a strip)
constexpr int SEGMENTS = 48;
constexpr float INNER = 0.85f;
constexpr float OUTER = 1.0f;
std::vector<float> verts;
for (int i = 0; i <= SEGMENTS; i++) {
float angle = 2.0f * 3.14159265f * static_cast<float>(i) / static_cast<float>(SEGMENTS);
float c = std::cos(angle), s = std::sin(angle);
// Outer vertex
verts.push_back(c * OUTER);
verts.push_back(s * OUTER);
verts.push_back(0.0f);
// Inner vertex
verts.push_back(c * INNER);
verts.push_back(s * INNER);
verts.push_back(0.0f);
}
selCircleVertCount = static_cast<int>((SEGMENTS + 1) * 2);
glGenVertexArrays(1, &selCircleVAO);
glGenBuffers(1, &selCircleVBO);
glBindVertexArray(selCircleVAO);
glBindBuffer(GL_ARRAY_BUFFER, selCircleVBO);
glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glBindVertexArray(0);
}
void Renderer::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) {
selCirclePos = pos;
selCircleRadius = radius;
selCircleColor = color;
selCircleVisible = true;
}
void Renderer::clearSelectionCircle() {
selCircleVisible = false;
}
void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) {
if (!selCircleVisible) return;
initSelectionCircle();
glm::mat4 model = glm::translate(glm::mat4(1.0f), selCirclePos);
model = glm::scale(model, glm::vec3(selCircleRadius));
glm::mat4 mvp = projection * view * model;
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDisable(GL_CULL_FACE);
glDepthMask(GL_FALSE);
glUseProgram(selCircleShader);
glUniformMatrix4fv(glGetUniformLocation(selCircleShader, "uMVP"), 1, GL_FALSE, &mvp[0][0]);
glUniform3fv(glGetUniformLocation(selCircleShader, "uColor"), 1, &selCircleColor[0]);
glBindVertexArray(selCircleVAO);
glDrawArrays(GL_TRIANGLE_STRIP, 0, selCircleVertCount);
glBindVertexArray(0);
glDepthMask(GL_TRUE);
glEnable(GL_CULL_FACE);
}
void Renderer::renderWorld(game::World* world) {
auto renderStart = std::chrono::steady_clock::now();
lastTerrainRenderMs = 0.0;
@ -1157,6 +1265,9 @@ void Renderer::renderWorld(game::World* world) {
characterRenderer->render(*camera, view, projection);
}
// Render selection circle under targeted creature
renderSelectionCircle(view, projection);
// Render WMO buildings (after characters, before UI)
if (wmoRenderer && camera) {
auto wmoStart = std::chrono::steady_clock::now();