Major refactoring - internal server state machine implemented

This commit is contained in:
Vladislav Khorev committed 2026-10-29 00:09:16 +06:00
1 parent 71c654e5b6
commit 66e93d46f4
48 files changed
+3948 -3696

No files matched your search

+116 -450
View File
@@ -9,10 +9,11 @@
#include "render/TextureManager.h"
#include "utils/Utils.h"
#include "TextModel.h"
#include "game_server/IGameServer.h"
#include "navigation/PathFinder.h"
#include "Location.h"
namespace FRG {
const float ATTACK_COOLDOWN_TIME = 1.6f;
extern float x;
extern float y;
@@ -26,6 +27,8 @@ void Character::loadAnimation(AnimationState animState, const std::string& filen
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
void Character::loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile) {
@@ -36,11 +39,14 @@ void Character::loadBinaryAnimation(AnimationState animState, const std::string&
} else {
data.totalFrames = 1;
}
gameServer_.submit(SetCharacterAnimationMetadataCommand{
locationId_, entityId_, animState, data.model.startingFrame, data.totalFrames});
}
std::unique_ptr<Character> Character::createFromState(CharacterState st, Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>();
c->state = std::move(st);
std::unique_ptr<Character> Character::createFromState(const CharacterState& st, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile) {
auto c = std::make_unique<Character>(st, server, locationId, entityId);
const std::string zip = zipFile ? zipFile : "";
@@ -56,7 +62,7 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
for (const auto& [animState, path] : c->state.creationInfo.animationPaths)
for (const auto& [animState, path] : c->characterState.creationInfo.animationPaths)
loadAnim(animState, path);
auto loadTex = [&](const std::string& path) -> std::shared_ptr<Texture> {
@@ -70,27 +76,27 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
};
if (!c->state.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(c->state.creationInfo.singleTexturePath);
if (!c->characterState.creationInfo.singleTexturePath.empty()) {
auto tex = loadTex(c->characterState.creationInfo.singleTexturePath);
if (tex) c->setTexture(tex);
}
for (const auto& [meshName, texPath] : c->state.creationInfo.meshTexturePaths) {
for (const auto& [meshName, texPath] : c->characterState.creationInfo.meshTexturePaths) {
auto tex = loadTex(texPath);
if (tex) c->setTexture(meshName, tex);
}
if (!c->state.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = c->state.creationInfo.weaponMeshPath;
if (!c->characterState.creationInfo.weaponMeshPath.empty()) {
const std::string& wp = c->characterState.creationInfo.weaponMeshPath;
try {
if (wp.size() >= 4 && wp.substr(wp.size() - 4) == ".bin")
c->weaponMesh.data = LoadModelFromBinFile(wp, zip);
else
c->weaponMesh.data = LoadFromTextFile02(wp, zip);
if (c->state.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(c->state.creationInfo.weaponMeshMove);
if (c->state.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(c->state.creationInfo.weaponMeshScale);
if (c->characterState.creationInfo.weaponMeshMove != Eigen::Vector3f::Zero())
c->weaponMesh.data.Move(c->characterState.creationInfo.weaponMeshMove);
if (c->characterState.creationInfo.weaponMeshScale != 1.0f)
c->weaponMesh.data.Scale(c->characterState.creationInfo.weaponMeshScale);
c->weaponMesh.RefreshVBO();
} catch (const std::exception& e) {
@@ -98,100 +104,14 @@ std::unique_ptr<Character> Character::createFromState(CharacterState st, Rendere
}
}
c->weaponTexture = loadTex(c->state.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = c->state.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = c->state.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = c->state.creationInfo.weaponInitialRotation.toRotationMatrix();
c->weaponTexture = loadTex(c->characterState.creationInfo.weaponTexturePath);
c->weaponAttachBoneName = c->characterState.creationInfo.weaponAttachBoneName;
c->weaponInitialPosition = c->characterState.creationInfo.weaponInitialPosition;
c->weaponInitialRotation = c->characterState.creationInfo.weaponInitialRotation.toRotationMatrix();
c->setTarget(c->state.position);
return c;
}
void Character::setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived, std::string callbackName) {
Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
(normalizedTarget - state.requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool alreadyMovingToTarget =
!state.pathWaypoints.empty() || (state.walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool stoppedAfterFailedPath =
state.pathWaypoints.empty() && (state.walkTarget - state.position).norm() <= TARGET_REPLAN_THRESHOLD;
if (!onArrived && sameRequestedTarget && (alreadyMovingToTarget || stoppedAfterFailedPath)) {
return;
}
state.requestedWalkTarget = normalizedTarget;
state.onArrivedCallback = std::move(onArrived);
state.onArrivedCallbackName = std::move(callbackName);
if (pathPlanner) {
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
return;
}
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
state.walkTarget = normalizedTarget;
}
void Character::stopAndRotateToTarget(int npcId)
{
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName = "";
state.walkTarget = state.position;
state.requestedWalkTarget = state.position;
state.faceTargetIndex = npcId;
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
void Character::setPathPlanner(PathPlanner planner) {
pathPlanner = std::move(planner);
}
void Character::resetPlayerAfterDeath() {
if (!state.isPlayer) return;
state.currentState = AnimationState::STAND;
state.resetAnim = true;
state.showWeapon = false;
state.battle_state = 0;
state.attack = 0;
}
void Character::forceReplan() {
if (!pathPlanner) return;
const Eigen::Vector3f normalizedTarget(state.requestedWalkTarget.x(), 0.f, state.requestedWalkTarget.z());
state.pathWaypoints = pathPlanner(state.position, normalizedTarget);
state.currentWaypointIndex = 0;
if (!state.pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : state.pathWaypoints) {
waypoint.y() = 0.f;
}
state.walkTarget = state.pathWaypoints.back();
return;
}
state.walkTarget = Eigen::Vector3f(state.position.x(), 0.f, state.position.z());
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
}
void Character::setTexture(std::shared_ptr<FRG::Texture> texture) {
for (auto& animEntry : animations) {
for (const auto& name : animEntry.second.model.meshNamesOrdered) {
@@ -205,275 +125,38 @@ void Character::setTexture(const std::string& meshName, std::shared_ptr<Texture>
}
AnimationState Character::resolveActiveState() const {
if (animations.count(state.currentState)) return state.currentState;
if (animations.count(characterState.currentState)) return characterState.currentState;
return AnimationState::STAND;
}
float Character::getSpeedMultiplier(float transitionProgress) const {
if (state.isPlayer) {
return 1.0f + (1.5f - 1.0f) * transitionProgress;
} else {
return 1.0f - (1.0f - 0.1f) * transitionProgress;
}
}
void Character::update(int64_t deltaMs, const CharacterResolver& resolver) {
if (!state.enabled) return;
if (state.slowMoTimeRemaining > 0.0f) {
state.slowMoTimeRemaining -= static_cast<float>(deltaMs) / 1000.0f;
float transitionProgress;
if (state.slowMoTimeRemaining > state.slowMoActiveTime - state.slowMoTransitionTime) {
float timeInTransition = state.slowMoActiveTime - state.slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / state.slowMoTransitionTime);
} else if (state.slowMoTimeRemaining < state.slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, state.slowMoTimeRemaining / state.slowMoTransitionTime);
} else {
transitionProgress = 1.0f;
}
float speedMultiplier = getSpeedMultiplier(transitionProgress);
deltaMs = static_cast<int64_t>(static_cast<float>(deltaMs) * speedMultiplier);
}
Character* attackTarget = resolver ? resolver(state.attackTargetIndex) : nullptr;
Character* faceTarget = resolver ? resolver(state.faceTargetIndex) : nullptr;
Eigen::Vector3f activeTarget;
Eigen::Vector3f lookTarget;
if (attackTarget == nullptr) {
state.battle_state = 0;
state.attack = 0;
}
if (attackTarget != nullptr) {
float distToGhost = (attackTarget->state.position - state.position).norm();
if (distToGhost >= state.visibleRange) {
if (state.isPlayer) {
setTarget(attackTarget->state.position);
}
state.battle_state = 0;
} else if (distToGhost < state.visibleRange && distToGhost >= kAttackRange) {
setTarget(attackTarget->state.position);
state.battle_state = 0;
} else {
state.battle_state = 1;
}
}
if (!state.pathWaypoints.empty() && state.currentWaypointIndex < state.pathWaypoints.size()) {
activeTarget = state.pathWaypoints[state.currentWaypointIndex];
} else {
activeTarget = state.walkTarget;
}
if (state.battle_state == 0) {
lookTarget = activeTarget;
} else {
lookTarget = attackTarget->state.position;
}
if (state.currentState == AnimationState::WALK || state.currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - state.position;
toTarget.y() = 0.f;
float dist = toTarget.norm();
if (dist > CharacterState::kWalkThreshold) {
Eigen::Vector3f dir = toTarget / dist;
float moveAmount = state.walkSpeed * static_cast<float>(deltaMs) / 1000.f;
if (moveAmount >= dist) {
state.position = activeTarget;
state.position.y() = 0.f;
} else {
state.position += dir * moveAmount;
}
state.targetFacingAngle = atan2(dir.x(), -dir.z());
state.currentState = AnimationState::WALK;
} else {
state.currentState = AnimationState::STAND;
const bool hasNextWaypoint = !state.pathWaypoints.empty() &&
state.currentWaypointIndex + 1 < state.pathWaypoints.size();
if (hasNextWaypoint) {
++state.currentWaypointIndex;
} else {
state.pathWaypoints.clear();
state.currentWaypointIndex = 0;
}
if (!hasNextWaypoint && state.onArrivedCallback) {
auto cb = std::move(state.onArrivedCallback);
state.onArrivedCallback = nullptr;
state.onArrivedCallbackName.clear();
cb();
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->state.position - state.position;
toFace.y() = 0.f;
float d = toFace.norm();
if (d > 1e-3f) {
state.targetFacingAngle = atan2(toFace.x() / d, -toFace.z() / d);
} else {
state.targetFacingAngle = state.facingAngle;
}
}
}
}
if (state.hp <= 0) {
if (state.currentState != AnimationState::ACTION_TO_DEATH &&
state.currentState != AnimationState::DEATH_IDLE) {
state.currentState = AnimationState::ACTION_TO_DEATH;
state.resetAnim = true;
state.attackTargetIndex = CharacterState::kNoTarget;
}
} else {
if (state.battle_state == 1) {
state.targetFacingAngle = atan2(lookTarget.x() - state.position.x(),
-(lookTarget.z() - state.position.z()));
if (state.currentState == AnimationState::STAND || state.currentState == AnimationState::WALK) {
state.currentState = AnimationState::STAND_TO_ACTION;
state.resetAnim = true;
}
if (state.canAttack && state.attack == 0 && state.attack_cooldown < 0.f &&
state.currentState == AnimationState::ACTION_IDLE) {
state.attack = 1;
state.attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (state.canAttack && state.attack_cooldown >= 0.f) {
state.attack_cooldown -= deltaMs / 1000.f;
}
if (state.attack == 1 && state.currentState == AnimationState::ACTION_IDLE) {
if (attackTarget != nullptr && attackTarget->state.hp > 0) {
state.currentState = AnimationState::ACTION_ATTACK;
state.resetAnim = true;
}
}
} else {
if (state.currentState == AnimationState::STAND_TO_ACTION ||
state.currentState == AnimationState::ACTION_IDLE ||
state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_TO_STAND;
state.resetAnim = true;
}
}
}
// Rotate toward target facing angle at constant angular speed
float angleDiff = state.targetFacingAngle - state.facingAngle;
while (angleDiff > static_cast<float>(M_PI)) angleDiff -= 2.f * static_cast<float>(M_PI);
while (angleDiff < -static_cast<float>(M_PI)) angleDiff += 2.f * static_cast<float>(M_PI);
float rotStep = state.rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotStep) {
state.facingAngle = state.targetFacingAngle;
// One-shot: once aligned, clear the face target so the NPC stops tracking.
state.faceTargetIndex = CharacterState::kNoTarget;
} else {
state.facingAngle += (angleDiff > 0.f ? rotStep : -rotStep);
}
if (!state.isPlayer && state.hp > 0.f && state.battle_state == 0 && state.maxHomeDrift > 0.f) {
state.homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (state.homeDriftCheckTimer <= 0.f) {
state.homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = state.homePosition - state.position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > state.maxHomeDrift) {
setTarget(state.homePosition);
}
}
}
void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
if (hitSparkEmitter.isConfigured()) {
hitSparkEmitter.update(static_cast<float>(deltaMs));
}
auto it = animations.find(state.currentState);
if (events) {
if (events->deathAnimationComplete && onDeathAnimComplete)
onDeathAnimComplete();
if (events->attackImpact && audioPlayer) {
audioPlayer->playSoundAsync(characterState.isPlayer
? "resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg"
: "resources/audio/13925__adcbicycle__21.ogg");
}
}
if (!characterState.enabled) return;
auto it = animations.find(characterState.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (state.resetAnim) {
state.resetAnim = false;
anim.currentFrame = 0;
}
int prevFrame = anim.currentFrame;
anim.currentFrame += static_cast<float>(deltaMs) / 24.f;
if (static_cast<int>(anim.currentFrame) >= 20 && state.currentState == AnimationState::STAND_TO_ACTION) {
state.showWeapon = true;
} else if (static_cast<int>(anim.currentFrame) <= 32 && state.currentState == AnimationState::ACTION_TO_STAND) {
state.showWeapon = true;
} else if (state.currentState == AnimationState::ACTION_ATTACK_2 ||
state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_IDLE) {
state.showWeapon = true;
} else {
state.showWeapon = false;
}
auto attackDirectionToTarget = [&]() {
Eigen::Vector3f dir = attackTarget->state.position - state.position;
dir.y() = 0.f;
float n = dir.norm();
if (n > 1e-3f) dir /= n;
else dir = Eigen::Vector3f::Zero();
return dir;
};
if (prevFrame < 19 && static_cast<int>(anim.currentFrame) >= 19 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
if (attackTarget != nullptr) {
attackTarget->applyDamage(10.f, attackDirectionToTarget(), index);
if (audioPlayer) {
if (state.isPlayer)
{
audioPlayer->playSoundAsync("resources/audio/547041__cogfirestudios__hit-swing-sword-small-3.ogg");
}
else
{
audioPlayer->playSoundAsync("resources/audio/13925__adcbicycle__21.ogg");
}
}
}
}
int frms = anim.model.animations[0].keyFrames[anim.model.animations[0].keyFrames.size() - 1].frame;
if (static_cast<int>(anim.currentFrame) >= frms) {
anim.currentFrame = anim.model.startingFrame;
if (state.currentState == AnimationState::STAND_TO_ACTION) {
state.currentState = AnimationState::ACTION_IDLE;
}
if (state.currentState == AnimationState::ACTION_TO_STAND) {
state.currentState = AnimationState::STAND;
}
if (state.currentState == AnimationState::ACTION_ATTACK) {
state.currentState = AnimationState::ACTION_IDLE;
state.attack = 0;
}
if (state.currentState == AnimationState::ACTION_TO_DEATH) {
state.currentState = AnimationState::DEATH_IDLE;
if (state.onDeathAnimComplete) {
state.onDeathAnimComplete();
}
}
}
anim.currentFrame = (std::min)(
characterState.animationFrame, static_cast<float>((std::max)(0, anim.totalFrames - 1)));
if (static_cast<int>(anim.currentFrame) != anim.lastFrame) {
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
static_cast<int>(anim.currentFrame));
if (!state.useGpuSkinning) {
if (!characterState.useGpuSkinning) {
anim.model.Interpolate(static_cast<int>(anim.currentFrame));
}
anim.lastFrame = static_cast<int>(anim.currentFrame);
@@ -488,9 +171,9 @@ void Character::draw(Renderer& renderer) {
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (!state.enabled) return;
if (!characterState.enabled) return;
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawGpuSkinning(renderer);
return;
}
@@ -509,10 +192,10 @@ void Character::draw(Renderer& renderer) {
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -563,10 +246,10 @@ void Character::drawGpuSkinning(Renderer& renderer) {
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(anim.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -610,24 +293,8 @@ bool Character::prepareGpuSkinning() {
return true;
}
void Character::tryAttackCombo()
{
logger() << "Try Attack Combo" << std::endl;
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
if (static_cast<int>(anim.currentFrame) >= 20 &&
(state.currentState == AnimationState::ACTION_ATTACK ||
state.currentState == AnimationState::ACTION_ATTACK_2)) {
logger() << "Try Attack Combo Success" << std::endl;
anim.currentFrame = anim.model.startingFrame;
}
}
void Character::drawAttachedWeapon(Renderer& renderer) {
if (!state.showWeapon) return;
if (!characterState.showWeapon) return;
if (weaponMesh.data.PositionData.size() == 0 || !weaponTexture) return;
auto it = animations.find(resolveActiveState());
@@ -643,10 +310,10 @@ void Character::drawAttachedWeapon(Renderer& renderer) {
Eigen::Matrix4f currentBone = anim.gpuSkinningShaderData.skinningMatrices[boneIdx] * bindBone;
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix(Eigen::Vector3f(currentBone.block<3, 1>(0, 3)));
renderer.RotateMatrix(Eigen::Matrix3f(currentBone.block<3, 3>(0, 0)));
renderer.TranslateMatrix(weaponInitialPosition);
@@ -660,8 +327,8 @@ void Character::drawAttachedWeapon(Renderer& renderer) {
// ==================== Shadow depth pass ====================
void Character::drawShadowDepth(Renderer& renderer) {
if (!state.enabled) return;
if (state.useGpuSkinning) {
if (!characterState.enabled) return;
if (characterState.useGpuSkinning) {
drawShadowDepthGpuSkinning(renderer);
} else {
drawShadowDepthCpu(renderer);
@@ -674,10 +341,10 @@ void Character::drawShadowDepthCpu(Renderer& renderer) {
if (it == animations.end()) return;
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -710,10 +377,10 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
renderer.shaderManager.PushShader(shadowSkinningShader);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
CheckGlError(__FILE__, __LINE__);
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
@@ -740,7 +407,7 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) {
// ==================== Main pass with shadows ====================
void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -748,7 +415,7 @@ void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightF
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawGpuSkinningWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
} else {
drawCpuWithShadow(renderer, lightFromCamera, shadowMapTex, lightDirCamera);
@@ -779,10 +446,10 @@ void Character::drawCpuWithShadow(Renderer& renderer, const Eigen::Matrix4f& lig
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -841,10 +508,10 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
CheckGlError(__FILE__, __LINE__);
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
@@ -887,7 +554,7 @@ void Character::drawNight(Renderer& renderer,
const Eigen::Matrix4f& cameraViewInverse,
const float* ambientColor, const float* fogColor)
{
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -895,7 +562,7 @@ void Character::drawNight(Renderer& renderer,
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawNightGpuSkinning(renderer, pointLightPosEye, pointLightDirEye, pointLightColors, pointLightCount,
pointLightWorldXZ, pointLightLimitXZ, cameraViewInverse, ambientColor, fogColor);
} else {
@@ -939,10 +606,10 @@ void Character::drawNightGpuSkinning(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(it->second.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -1010,10 +677,10 @@ void Character::drawNightCpu(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -1045,14 +712,14 @@ void Character::drawNightWithShadow(Renderer& renderer,
const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex,
const float* ambientColor, const float* fogColor)
{
if (!state.enabled) return;
if (!characterState.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
if (state.useGpuSkinning) {
if (characterState.useGpuSkinning) {
drawNightGpuSkinningWithShadow(renderer, plPosEye, plDirEye, plColors, plCount,
plWorldXZ, plLimitXZ, cameraViewInverse,
lightFromCamera, shadowMapTex, ambientColor, fogColor);
@@ -1107,10 +774,10 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
renderer.RenderUniformMatrix4fvArray(boneMatricesUniform,
static_cast<int>(it->second.gpuSkinningShaderData.skinningMatrices.size()), false,
@@ -1191,10 +858,10 @@ void Character::drawNightCpuWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix({ characterState.position.x(), characterState.position.y(), characterState.position.z() });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-characterState.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(characterState.modelScale);
renderer.RotateMatrix(characterState.modelCorrectionRotation.toRotationMatrix());
auto& anim = it->second;
for (const auto& name : anim.model.meshNamesOrdered) {
@@ -1232,16 +899,15 @@ void Character::setupHitSparks(std::shared_ptr<Texture> sparkTexture) {
hitSparkEmitter.markConfigured();
}
void Character::applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection, int attackerIndex) {
logger() << "Character::applyDamage" << std::endl;
state.hp -= damageAmount;
if (state.hp < 0) state.hp = 0;
if (state.onHpChanged) state.onHpChanged(state.hp, state.initialHp, attackerIndex);
void Character::presentDamage(float hp, float maxHp,
const Eigen::Vector3f& attackDirection,
const std::string& attackerId) {
if (onHpChanged)
onHpChanged(hp, maxHp, attackerId);
if (!hitSparkEmitter.isConfigured()) return;
Eigen::Vector3f emitPos = state.position + Eigen::Vector3f(0.f, 1.f, 0.f);
Eigen::Vector3f emitPos = characterState.position + Eigen::Vector3f(0.f, 1.f, 0.f);
hitSparkEmitter.resetEmissionPoints({ emitPos });
hitSparkEmitter.setEmissionDirection(attackDirection);
@@ -1262,12 +928,12 @@ void Character::drawName(TextRenderer& textRenderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
if (state.isPlayer) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
if (state.npcName.empty()) return;
if (characterState.isPlayer) return;
if (!characterState.enabled) return;
if (characterState.hp <= 0.f) return;
if (characterState.npcName.empty()) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f);
Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.83f, characterState.position.z(), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return;
@@ -1278,11 +944,11 @@ void Character::drawName(TextRenderer& textRenderer,
float screenX = (ndcX * 0.5f + 0.5f) * Environment::projectionWidth;
float screenY = (ndcY * 0.5f + 0.5f) * Environment::projectionHeight;
std::array<float, 4> color = state.canAttack
std::array<float, 4> color = characterState.canAttack
? std::array<float, 4>{ 1.f, 0.f, 0.f, 1.f }
: std::array<float, 4>{ 0.f, 1.f, 0.f, 1.f };
const std::string& translatedName = Character::nameTranslations.translateRef(state.npcName, g_currentLanguage);
const std::string& translatedName = Character::nameTranslations.translateRef(characterState.npcName, g_currentLanguage);
textRenderer.drawText(translatedName, screenX, screenY, 1.0f, true, color);
}
@@ -1291,12 +957,12 @@ void Character::drawHealthBar(Renderer& renderer,
const Eigen::Matrix4f& cameraViewMatrix,
const Eigen::Matrix4f& projectionMatrix)
{
if (state.isPlayer || !state.canAttack) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
if (state.initialHp <= 0.f) return;
if (characterState.isPlayer || !characterState.canAttack) return;
if (!characterState.enabled) return;
if (characterState.hp <= 0.f) return;
if (characterState.initialHp <= 0.f) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.75f, state.position.z(), 1.f);
Eigen::Vector4f worldPos(characterState.position.x(), characterState.position.y() + 1.75f, characterState.position.z(), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return;
float ndcX = clip.x() / clip.w();
@@ -1314,7 +980,7 @@ void Character::drawHealthBar(Renderer& renderer,
const float bottom = sy - barH * 0.5f;
const float top = sy + barH * 0.5f;
float frac = state.hp / state.initialHp;
float frac = characterState.hp / characterState.initialHp;
if (frac < 0.f) frac = 0.f;
if (frac > 1.f) frac = 1.f;
const float split = left + barW * frac;
+33 -46
View File
@@ -11,30 +11,25 @@
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <utility>
#include "dialogue/TranslationDatabase.h"
#include "AudioPlayerAsync.h"
namespace FRG {
class TextRenderer;
class IGameServer;
class Character {
public:
Character(const CharacterState& boundState, IGameServer& server,
std::string locationId, std::string entityId)
: characterState(boundState), gameServer_(server),
locationId_(std::move(locationId)), entityId_(std::move(entityId)){}
const std::string& entityId() const { return entityId_; }
void bindToLocation(std::string locationId) { locationId_ = std::move(locationId); }
static constexpr float kAttackRange = 1.25f;
// Resolves a character index to a live Character pointer.
// kNoTarget (-1) → nullptr, kPlayerIndex (-2) → Location::player, 0..n → npcs[n].
using CharacterResolver = std::function<Character*(int index)>;
using PathPlanner = std::function<std::vector<Eigen::Vector3f>(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end)>;
// All serializable game state (position, hp, flags, pathfinding, etc.)
CharacterState state;
int index = CharacterState::kNoTarget;
// ---- Asset loading ----
void loadAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
void loadBinaryAnimation(AnimationState animState, const std::string& filename, const std::string& zipFile = "");
@@ -46,31 +41,19 @@ public:
// Creates a fully-loaded Character from a CharacterState (including its creationInfo).
// Loads all animations, textures, and weapon mesh/texture from the paths stored in creationInfo.
static std::unique_ptr<Character> createFromState(CharacterState st, Renderer& renderer, const char* zipFile);
static std::unique_ptr<Character> createFromState(
const CharacterState& initialState, IGameServer& server,
const std::string& locationId, const std::string& entityId,
Renderer& renderer, const char* zipFile);
// ---- Per-frame update ----
// resolver maps character indices to live pointers so attackTargetIndex / faceTargetIndex
// can be dereferenced inside update(). Pass nullptr only if no targets are set.
void update(int64_t deltaMs, const CharacterResolver& resolver = nullptr);
void setPathPlanner(PathPlanner planner);
// ---- Navigation (path data lives in state; pathPlanner lives here) ----
// callbackName, if given, is the global Lua function name backing onArrived —
// stored in state.onArrivedCallbackName so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void setTarget(const Eigen::Vector3f& target, std::function<void()> onArrived = nullptr, std::string callbackName = "");
void stopAndRotateToTarget(int npcId);
void forceReplan();
void stopInPlace() { state.stopInPlace(); }
// ---- Reset ----
void resetPlayerAfterDeath();
void tryAttackCombo();
// Applies authoritative animation state to runtime resources and consumes
// presentation-only events emitted by CharacterState::update.
void update(int64_t deltaMs, const CharacterUpdateEvents* events = nullptr);
// ---- Damage ----
void applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection = Eigen::Vector3f::Zero(), int attackerIndex = CharacterState::kNoTarget);
void presentDamage(float hp, float maxHp, const Eigen::Vector3f& attackDirection,
const std::string& attackerId);
// ---- Spark setup ----
void setupHitSparks(std::shared_ptr<Texture> sparkTexture);
@@ -102,13 +85,10 @@ public:
const float* ambientColor, const float* fogColor);
// ---- Query helpers (delegate to state) ----
bool isMoving() const { return state.isMoving(); }
const Eigen::Vector3f& getRequestedWalkTarget() const { return state.getRequestedWalkTarget(); }
Eigen::Vector3f getCurrentNavigationTarget() const { return state.getCurrentNavigationTarget(); }
float getHp() const { return state.getHp(); }
void setHp(float newHp) { state.setHp(newHp); }
float getSpeedMultiplier(float transitionProgress) const;
bool isMoving() const { return characterState.isMoving(); }
const Eigen::Vector3f& getRequestedWalkTarget() const { return characterState.getRequestedWalkTarget(); }
Eigen::Vector3f getCurrentNavigationTarget() const { return characterState.getCurrentNavigationTarget(); }
float getHp() const { return characterState.getHp(); }
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
VertexRenderStruct weaponMesh;
@@ -123,16 +103,23 @@ public:
static Dialogue::TranslationDatabase nameTranslations;
private:
friend class Game;
friend class Location;
friend class ScriptEngine;
friend class LocationEditor;
friend class PathVisualization;
friend class InternalGameServer;
const CharacterState& characterState; // private non-owning binding to server-owned storage
IGameServer& gameServer_;
std::string locationId_;
std::string entityId_;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, const std::string&)> onHpChanged;
std::map<AnimationState, BoneAnimationDataNew> animations;
VertexRenderStruct modelMutable;
VertexRenderStruct healthBarMesh;
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
PathPlanner pathPlanner;
static constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
static constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
AnimationState resolveActiveState() const;
bool prepareGpuSkinning();
void drawAttachedWeapon(Renderer& renderer);
+308 -6
View File
@@ -1,7 +1,17 @@
#include "CharacterState.h"
#include "LocationState.h"
#include <algorithm>
#include <cmath>
namespace FRG {
namespace {
constexpr float ATTACK_COOLDOWN_TIME = 1.6f;
constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
constexpr float PI = 3.14159265358979323846f;
}
bool CharacterState::isMoving() const {
Eigen::Vector3f toTarget = walkTarget - position;
toTarget.y() = 0.f;
@@ -20,14 +30,304 @@ void CharacterState::stopInPlace() {
requestedWalkTarget = walkTarget;
pathWaypoints.clear();
currentWaypointIndex = 0;
onArrivedCallback = nullptr;
onArrivedCallbackName.clear();
}
void CharacterState::setTarget(
const Eigen::Vector3f& target,
std::string callbackName,
const LocationState& location,
const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
(normalizedTarget - requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool alreadyMovingToTarget =
!pathWaypoints.empty() ||
(walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD;
const bool stoppedAfterFailedPath =
pathWaypoints.empty() &&
(walkTarget - position).norm() <= TARGET_REPLAN_THRESHOLD;
if (callbackName.empty() && sameRequestedTarget &&
(alreadyMovingToTarget || stoppedAfterFailedPath)) {
return;
}
requestedWalkTarget = normalizedTarget;
onArrivedCallbackName = std::move(callbackName);
pathWaypoints = location.planPathExceptMyself(position, normalizedTarget, entityId);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear();
}
void CharacterState::forceReplan(
const LocationState& location, const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
pathWaypoints = location.planPathExceptMyself(position, normalizedTarget, entityId);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear();
}
void CharacterState::stopAndRotateToTarget(std::string characterId)
{
stopInPlace();
faceTargetId = std::move(characterId);
}
void CharacterState::resetPlayerAfterDeath()
{
if (!isPlayer) return;
currentState = AnimationState::STAND;
resetAnim = true;
showWeapon = false;
battle_state = 0;
attack = 0;
}
void CharacterState::tryAttackCombo()
{
if (static_cast<int>(animationFrame) < 20 ||
(currentState != AnimationState::ACTION_ATTACK &&
currentState != AnimationState::ACTION_ATTACK_2)) {
return;
}
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
void CharacterState::setHp(float newHp) {
hp = newHp;
if (hp < 0.f) hp = 0.f;
if (onHpChanged) onHpChanged(hp, initialHp, CharacterState::kNoTarget);
}
void CharacterState::setAnimationMetadata(
AnimationState state, int startFrame, int frameCount)
{
const auto index = static_cast<std::size_t>(state);
if (index < animationFrameCounts.size()) {
animationStartFrames[index] = (std::max)(0, startFrame);
animationFrameCounts[index] = (std::max)(1, frameCount);
}
}
CharacterUpdateEvents CharacterState::update(
std::int64_t deltaMs, const CharacterUpdateContext& context)
{
CharacterUpdateEvents events;
if (!enabled) return events;
if (slowMoTimeRemaining > 0.0f) {
slowMoTimeRemaining = (std::max)(
0.0f, slowMoTimeRemaining - static_cast<float>(deltaMs) / 1000.0f);
float transitionProgress = 1.0f;
if (slowMoTimeRemaining > slowMoActiveTime - slowMoTransitionTime) {
const float timeInTransition = slowMoActiveTime - slowMoTimeRemaining;
transitionProgress = (std::min)(1.0f, timeInTransition / slowMoTransitionTime);
} else if (slowMoTimeRemaining < slowMoTransitionTime) {
transitionProgress = (std::max)(0.0f, slowMoTimeRemaining / slowMoTransitionTime);
}
const float speedMultiplier = isPlayer
? 1.0f + 0.5f * transitionProgress
: 1.0f - 0.9f * transitionProgress;
deltaMs = static_cast<std::int64_t>(
static_cast<float>(deltaMs) * speedMultiplier);
}
const CharacterObservation* attackTarget = context.attackTarget;
const CharacterObservation* faceTarget = context.faceTarget;
if (!attackTarget) {
battle_state = 0;
attack = 0;
attackTargetId.clear();
} else {
const float distance = (attackTarget->position - position).norm();
if (distance >= visibleRange) {
if (isPlayer &&
(attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else if (distance >= 1.25f) {
if ((attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) {
events.navigationRequested = true;
events.navigationTarget = attackTarget->position;
}
battle_state = 0;
} else {
battle_state = 1;
}
}
const Eigen::Vector3f activeTarget = getCurrentNavigationTarget();
const Eigen::Vector3f lookTarget =
battle_state == 1 && attackTarget ? attackTarget->position : activeTarget;
if (currentState == AnimationState::WALK || currentState == AnimationState::STAND) {
Eigen::Vector3f toTarget = activeTarget - position;
toTarget.y() = 0.f;
const float distance = toTarget.norm();
if (distance > kWalkThreshold) {
const Eigen::Vector3f direction = toTarget / distance;
const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f;
position = moveAmount >= distance ? activeTarget : position + direction * moveAmount;
position.y() = 0.f;
targetFacingAngle = std::atan2(direction.x(), -direction.z());
currentState = AnimationState::WALK;
} else {
currentState = AnimationState::STAND;
const bool hasNextWaypoint =
!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size();
if (hasNextWaypoint) {
++currentWaypointIndex;
} else {
pathWaypoints.clear();
currentWaypointIndex = 0;
if (!onArrivedCallbackName.empty()) {
events.arrivedCallbackName = onArrivedCallbackName;
onArrivedCallbackName.clear();
}
}
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->position - position;
toFace.y() = 0.f;
const float distanceToFace = toFace.norm();
targetFacingAngle = distanceToFace > 1e-3f
? std::atan2(toFace.x() / distanceToFace, -toFace.z() / distanceToFace)
: facingAngle;
} else if (!faceTargetId.empty()) {
faceTargetId.clear();
}
}
}
if (hp <= 0.f) {
if (currentState != AnimationState::ACTION_TO_DEATH &&
currentState != AnimationState::DEATH_IDLE) {
currentState = AnimationState::ACTION_TO_DEATH;
resetAnim = true;
attackTargetId.clear();
}
} else if (battle_state == 1 && attackTarget) {
targetFacingAngle = std::atan2(
lookTarget.x() - position.x(), -(lookTarget.z() - position.z()));
if (currentState == AnimationState::STAND || currentState == AnimationState::WALK) {
currentState = AnimationState::STAND_TO_ACTION;
resetAnim = true;
}
if (canAttack && attack == 0 && attack_cooldown < 0.f &&
currentState == AnimationState::ACTION_IDLE) {
attack = 1;
attack_cooldown = ATTACK_COOLDOWN_TIME;
}
if (canAttack && attack_cooldown >= 0.f)
attack_cooldown -= static_cast<float>(deltaMs) / 1000.f;
if (attack == 1 && currentState == AnimationState::ACTION_IDLE &&
attackTarget->hp > 0.f) {
currentState = AnimationState::ACTION_ATTACK;
resetAnim = true;
}
} else if (currentState == AnimationState::STAND_TO_ACTION ||
currentState == AnimationState::ACTION_IDLE ||
currentState == AnimationState::ACTION_ATTACK) {
currentState = AnimationState::ACTION_TO_STAND;
resetAnim = true;
}
float angleDiff = targetFacingAngle - facingAngle;
while (angleDiff > PI) angleDiff -= 2.f * PI;
while (angleDiff < -PI) angleDiff += 2.f * PI;
const float rotationStep = rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotationStep) {
facingAngle = targetFacingAngle;
faceTargetId.clear();
} else {
facingAngle += angleDiff > 0.f ? rotationStep : -rotationStep;
}
if (!isPlayer && hp > 0.f && battle_state == 0 && maxHomeDrift > 0.f) {
homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f;
if (homeDriftCheckTimer <= 0.f) {
homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL;
Eigen::Vector3f toHome = homePosition - position;
toHome.y() = 0.f;
if (!isMoving() && toHome.norm() > maxHomeDrift) {
events.navigationRequested = true;
events.navigationTarget = homePosition;
}
}
}
if (resetAnim) {
resetAnim = false;
const auto index = static_cast<std::size_t>(currentState);
animationFrame = index < animationStartFrames.size()
? static_cast<float>(animationStartFrames[index]) : 0.f;
}
const int previousFrame = static_cast<int>(animationFrame);
animationFrame += static_cast<float>(deltaMs) / 24.f;
if (previousFrame < 19 && static_cast<int>(animationFrame) >= 19 &&
(currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) &&
attackTarget) {
events.attackImpact = true;
events.attackTargetId = attackTargetId;
events.attackDirection = attackTarget->position - position;
events.attackDirection.y() = 0.f;
const float length = events.attackDirection.norm();
if (length > 1e-3f) events.attackDirection /= length;
else events.attackDirection = Eigen::Vector3f::Zero();
}
showWeapon =
(static_cast<int>(animationFrame) >= 20 && currentState == AnimationState::STAND_TO_ACTION) ||
(static_cast<int>(animationFrame) <= 32 && currentState == AnimationState::ACTION_TO_STAND) ||
currentState == AnimationState::ACTION_ATTACK_2 ||
currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_IDLE;
const auto animationIndex = static_cast<std::size_t>(currentState);
const int frameCount = animationIndex < animationFrameCounts.size()
? animationFrameCounts[animationIndex] : 1;
if (frameCount > 1 && static_cast<int>(animationFrame) >= frameCount - 1) {
if (currentState == AnimationState::STAND_TO_ACTION)
currentState = AnimationState::ACTION_IDLE;
else if (currentState == AnimationState::ACTION_TO_STAND)
currentState = AnimationState::STAND;
else if (currentState == AnimationState::ACTION_ATTACK ||
currentState == AnimationState::ACTION_ATTACK_2) {
currentState = AnimationState::ACTION_IDLE;
attack = 0;
} else if (currentState == AnimationState::ACTION_TO_DEATH) {
currentState = AnimationState::DEATH_IDLE;
events.deathAnimationComplete = true;
}
const auto nextIndex = static_cast<std::size_t>(currentState);
animationFrame = nextIndex < animationStartFrames.size()
? static_cast<float>(animationStartFrames[nextIndex]) : 0.f;
}
return events;
}
void CharacterState::save(nlohmann::json& out) const
@@ -41,8 +341,8 @@ void CharacterState::save(nlohmann::json& out) const
out["currentState"] = static_cast<int>(currentState);
out["attack_cooldown"] = attack_cooldown;
out["showWeapon"] = showWeapon;
out["attackTargetIndex"] = attackTargetIndex;
out["faceTargetIndex"] = faceTargetIndex;
out["attackTargetId"] = attackTargetId;
out["faceTargetId"] = faceTargetId;
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() };
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() };
out["requestedWalkTarget"] = { requestedWalkTarget.x(), requestedWalkTarget.y(), requestedWalkTarget.z() };
@@ -53,6 +353,7 @@ void CharacterState::save(nlohmann::json& out) const
out["pathWaypoints"] = std::move(wpArr);
out["currentWaypointIndex"] = currentWaypointIndex;
out["homeDriftCheckTimer"] = homeDriftCheckTimer;
out["animationFrame"] = animationFrame;
out["visibleRange"] = visibleRange;
if (!onArrivedCallbackName.empty())
out["onArrivedCallbackName"] = onArrivedCallbackName;
@@ -76,8 +377,8 @@ void CharacterState::load(const nlohmann::json& in)
currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState)));
attack_cooldown = in.value("attack_cooldown", attack_cooldown);
showWeapon = in.value("showWeapon", showWeapon);
attackTargetIndex = in.value("attackTargetIndex", attackTargetIndex);
faceTargetIndex = in.value("faceTargetIndex", faceTargetIndex);
attackTargetId = in.value("attackTargetId", attackTargetId);
faceTargetId = in.value("faceTargetId", faceTargetId);
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition);
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget);
if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3(in["requestedWalkTarget"], requestedWalkTarget);
@@ -89,6 +390,7 @@ void CharacterState::load(const nlohmann::json& in)
}
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
animationFrame = in.value("animationFrame", animationFrame);
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
}
+59 -20
View File
@@ -2,7 +2,9 @@
#include <string>
#include <vector>
#include <unordered_map>
#include <functional>
#include <array>
#include <cstdint>
#include <memory>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -12,6 +14,10 @@ namespace FRG {
constexpr float VISIBLE_RANGE = 6.f;
struct CharacterUpdateContext;
struct CharacterUpdateEvents;
struct LocationState;
enum class AnimationState {
STAND = 0,
WALK = 1,
@@ -24,6 +30,8 @@ enum class AnimationState {
DEATH_IDLE = 8,
};
// Paths and fixed parameters needed to recreate a Character's visual assets
// from scratch (e.g. after loading a saved game).
struct CharacterCreationInfo {
@@ -45,19 +53,14 @@ struct CharacterCreationInfo {
float weaponMeshScale = 1.0f;
};
// All serializable game state for a character.
// This is a member of Character; rendering-only data lives in Character itself.
// All serializable authoritative state for a character. GameState or
// LocationGameState owns it; rendering-only data lives in Character.
class CharacterState : public ISaveable {
public:
// Special index values for attackTargetIndex / faceTargetIndex
static constexpr int kNoTarget = -1; // no target (was nullptr)
static constexpr int kPlayerIndex = -2; // refers to Location::player
// Distance below which the character is considered to have reached its target.
static constexpr float kWalkThreshold = 0.05f;
// --- Identity ---
std::string npcId;
std::string npcName;
bool isPlayer = false;
bool enabled = true;
@@ -84,9 +87,11 @@ public:
// Weapon visibility toggle (changes during animation)
bool showWeapon = true;
// --- Index-based character references (replace raw pointers) ---
int attackTargetIndex = kNoTarget;
int faceTargetIndex = kNoTarget;
// --- Stable character references ---
// Empty means no target. "player" is the campaign player; NPC IDs are
// location-local and validated while loading location content.
std::string attackTargetId;
std::string faceTargetId;
// --- Home position for drift recovery ---
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero();
@@ -97,6 +102,13 @@ public:
float slowMoActiveTime = 6.0f;
float slowMoTransitionTime = 0.5f;
// --- Authoritative logical animation clock ---
// Frame counts are immutable metadata copied from loaded clips during
// character construction. Simulation advances animationFrame itself.
float animationFrame = 0.0f;
std::array<int, 9> animationStartFrames = {{0, 0, 0, 0, 0, 0, 0, 0, 0}};
std::array<int, 9> animationFrameCounts = {{1, 1, 1, 1, 1, 1, 1, 1, 1}};
// --- Movement / visual parameters ---
float walkSpeed = 3.0f;
float rotationSpeed = 4.0f;
@@ -115,14 +127,8 @@ public:
// Internal drift-check timer (serializable so it survives save/load).
float homeDriftCheckTimer = 5.0f;
// --- Non-serializable callbacks (re-wired after each load) ---
std::function<void()> onArrivedCallback;
std::function<void()> onDeathAnimComplete;
std::function<void(float, float, int)> onHpChanged;
// Global Lua function name backing onArrivedCallback, if it was sourced from a
// script (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback
// can be re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the character reaches the final waypoint.
// The name is serialized, so no runtime callback needs to be rehydrated.
std::string onArrivedCallbackName;
// --- Asset / creation info (used to reload visuals from a saved state) ---
@@ -133,12 +139,45 @@ public:
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
Eigen::Vector3f getCurrentNavigationTarget() const;
void stopInPlace();
void setTarget(const Eigen::Vector3f& target,
std::string callbackName,
const LocationState& location,
const std::string& entityId);
void forceReplan(const LocationState& location,
const std::string& entityId);
void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath();
void tryAttackCombo();
float getHp() const { return hp; }
void setHp(float newHp);
CharacterUpdateEvents update(std::int64_t deltaMs, const CharacterUpdateContext& context);
void setAnimationMetadata(AnimationState state, int startFrame, int frameCount);
// --- Serialisation (runtime mutable fields only; creation info is reloaded from config) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
};
using PlayerStateMap =
std::unordered_map<std::string, std::shared_ptr<CharacterState>>;
struct CharacterObservation {
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float hp = 0.f;
};
struct CharacterUpdateContext {
const CharacterObservation* attackTarget = nullptr;
const CharacterObservation* faceTarget = nullptr;
};
struct CharacterUpdateEvents {
bool attackImpact = false;
bool deathAnimationComplete = false;
bool navigationRequested = false;
Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero();
std::string arrivedCallbackName;
std::string attackTargetId;
Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero();
};
} // namespace FRG
+149 -84
View File
@@ -77,15 +77,18 @@ namespace FRG
Location* Game::currentLocation() const {
if (gameState.currentLocationName.empty()) return nullptr;
auto it = gameState.locations.find(gameState.currentLocationName);
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
const auto& locations = internalGameServer.runtimeLocations();
auto it = locations.find(gameState.currentLocationName);
return (it != locations.end()) ? it->second.get() : nullptr;
}
Game::Game()
: newTickCount(0)
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, internalGameServer()
, gameState(internalGameServer.hostState())
, menuManager(renderer, internalGameServer, *audioPlayer)
, newTickCount(0)
, lastTickCount(0)
, audioPlayer(std::make_unique<AudioPlayerAsync>())
, menuManager(renderer, gameState, *audioPlayer)
{
}
@@ -391,15 +394,23 @@ namespace FRG
menuManager.skipCutsceneFunc = [this]() {
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
};
menuManager.callTaxiFunc = [this]() {
if (currentLocation()) currentLocation()->onTaxiCalled();
};
});
loadSteps.push([this]() {
try {
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
menuManager.setup(CONST_ZIP_FILE);
gameState.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestNew, quest->definition.title);
};
gameState.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestCompleted, quest->definition.title);
};
gameState.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
if (quest) menuManager.showToast(menuManager.toastTextureQuestFailed, quest->definition.title);
};
logger() << "UI loaded successfully" << std::endl;
}
catch (const std::exception& e) {
@@ -515,7 +526,8 @@ namespace FRG
}
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
return player && loadedCampaignId == campaignId && gameState.locations.count(locationId) > 0;
return player && loadedCampaignId == campaignId
&& internalGameServer.runtimeLocations().count(locationId) > 0;
}
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
@@ -556,20 +568,24 @@ namespace FRG
void Game::loadCampaign(const std::string& campaignId) {
loadedCampaignId = campaignId;
gameState.campaignId = campaignId;
gameState.storedLocationStates.clear();
gameState.locations.clear();
gameState.players.clear();
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
// The player stays loaded for the whole campaign; locations come and go around him.
player = Location::createPlayer(renderer, audioPlayer.get());
auto& playerState = gameState.players["player"];
if (!playerState) playerState = std::make_shared<CharacterState>();
player = Location::createPlayer(renderer, audioPlayer.get(), *playerState, internalGameServer);
if (player)
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
}
void Game::unloadCampaign() {
unloadLocation(false);
player.reset();
gameState.players.clear();
loadedCampaignId.clear();
gameState.storedLocationStates.clear();
gameState.locations.clear();
renderer.textureManager.UnloadUnused(); // the player's textures
}
@@ -580,23 +596,24 @@ namespace FRG
<< locationId << "'" << std::endl;
return;
}
const bool initializeLocation =
!gameState.locations[locationId].hasPersistentState;
std::shared_ptr<Location> loc = createLocation(*config);
wireLocation(loc.get());
if (shadowMap) loc->shadowMap = shadowMap;
if (initializeLocation)
loc->scriptEngine.callLocationInitCallback();
// Back to how the player left it (or to the start state's snapshot of it).
auto stored = gameState.storedLocationStates.find(locationId);
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
gameState.locations[locationId] = loc;
internalGameServer.bindRuntimeLocation(locationId, loc);
}
void Game::unloadLocation(bool storeState) {
for (auto& [name, loc] : gameState.locations) {
if (!storeState) continue;
nlohmann::json locJson;
loc->save(locJson);
gameState.storedLocationStates[name] = std::move(locJson);
if (storeState) internalGameServer.synchronizeRuntimeAuxiliaryState();
else {
for (const auto& [name, loc] : internalGameServer.runtimeLocations())
gameState.locations.erase(name);
}
gameState.locations.clear();
internalGameServer.runtimeLocations().clear();
// Textures only the location's models and characters used are freed now.
renderer.textureManager.UnloadUnused();
}
@@ -619,11 +636,12 @@ namespace FRG
Location* loc = currentLocation();
if (!loc) return;
if (loc->player) {
loc->player->state.position = destPos;
loc->player->setTarget(destPos);
loc->player->stopInPlace();
loc->player->state.facingAngle = destRotY;
loc->player->state.targetFacingAngle = destRotY;
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
workingState.position = destPos;
workingState.facingAngle = destRotY;
workingState.targetFacingAngle = destRotY;
});
loc->stopCharacter(*loc->player);
}
loc->state.cameraAzimuth = destRotY;
loc->state.isDarklands = gameState.isDarklands;
@@ -632,14 +650,16 @@ namespace FRG
loc->scriptEngine.callLocationEnterCallback();
//Reset taxi
loc->state.taxiCar = loc->state.taxiCarInitialState;
loc->state.taxiArrivedCallbackCalled = false;
auto locationState = gameState.locations.find(destName);
if (locationState != gameState.locations.end())
locationState->second.resetTaxi();
gameState.taxiIsCalled = false;
menuManager.onLocationChanged(destName);
});
}
void Game::performFullReset() {
gameState.currentLocationName.clear();
gameState.isDarklands = false;
@@ -666,9 +686,9 @@ namespace FRG
// Rebuild the loaded location from its config; the others have no stored state
// any more, so they are built fresh when the player first goes there.
gameState.storedLocationStates.clear();
gameState.locations.clear();
std::vector<std::string> loadedIds;
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loadedIds.push_back(name);
unloadLocation(false);
for (const std::string& id : loadedIds) loadLocation(id);
@@ -698,15 +718,8 @@ namespace FRG
if (!content.empty()) {
try {
nlohmann::json root = nlohmann::json::parse(content);
const std::string campaignId = gameState.campaignId;
// GameState::load() merges into existing maps rather than replacing them,
// so any Lua globals set during play that aren't in the pristine snapshot
// must be cleared explicitly before loading it.
gameState.globalInts.clear();
gameState.globalFloats.clear();
gameState.load(root); // applies to the loaded location in place and stores
internalGameServer.load(root); // applies to the loaded location in place and stores
// the other locations' states for when they load.
gameState.campaignId = campaignId; // the snapshot may predate campaigns
return;
} catch (const std::exception& e) {
logger() << "[reset] Failed to parse " << path << ": " << e.what()
@@ -729,7 +742,7 @@ namespace FRG
void Game::saveInitialState() {
nlohmann::json root;
gameState.save(root);
internalGameServer.save(root);
// Written next to the executable; copy it over the campaign's start-state file.
const std::string startStatePath = activeCampaign().startStatePath;
const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1);
@@ -747,20 +760,28 @@ namespace FRG
std::shared_ptr<Location> Game::createLocation(const LocationConfig& config)
{
LocationGameState& authoritative = gameState.locations[config.id];
if (!authoritative.location) authoritative.location = std::make_shared<LocationState>();
if (authoritative.location->navigationMaps.empty())
authoritative.location->initNavigation(config.navigationJsonPaths);
LocationSetup params;
params.gameObjectsJsonPath = config.gameObjectsJsonPath;
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
params.npcsJsonPath = config.npcsJsonPath;
params.dialoguesJsonPath = config.dialoguesJsonPath;
params.navigationJsonPaths = config.navigationJsonPaths;
//params.navigationJsonPaths = config.navigationJsonPaths;
params.scriptPath = config.scriptPath;
params.teleportsJsonPath = config.teleportsJsonPath;
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
params.lightsJsonPath = config.lightsJsonPath;
params.playerPosition = config.playerPosition;
params.player = player;
params.gameState = &authoritative;
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
auto loc = std::make_shared<Location>(
renderer, gameState.inventory, *authoritative.location, internalGameServer, config.id);
loc->setup(params, &gameState.questJournal, audioPlayer.get());
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
@@ -779,8 +800,8 @@ namespace FRG
menuManager.setChatUnread(idx, unread, msg);
};
for (auto& npc : loc->npcs) {
if (npc && npc->state.canAttack) {
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
if (npc && npc->characterState.canAttack) {
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
}
}
loc->requestDarklandsPlayBattleMusic = [this]() {
@@ -792,24 +813,23 @@ namespace FRG
menuManager.lastMusicTrack = "music/bishkek fight calm.ogg";
};
if (config.lockInteractiveObjectsDuringTutorial) {
// Objects can be picked up once the tutorial reaches "pick up an item".
loc->areInteractiveObjectsLocked = [this]() {
return gameState.tutorialStep < TutorialStep::PickUpItem;
};
}
authoritative.lockInteractiveObjectsDuringTutorial =
config.lockInteractiveObjectsDuringTutorial;
if (config.taxi) {
const LocationConfig::Taxi& taxi = *config.taxi;
loc->state.taxiCarTriggerPosition = taxi.triggerPosition;
loc->state.taxiCarWaypoints = taxi.waypoints;
loc->state.taxiCarInitialState.position = taxi.initialPosition;
loc->state.taxiCarInitialState.rotation = taxi.initialRotation;
loc->state.taxiCarInitialState.mode = NpcCar::Mode::NONE;
loc->state.taxiCarInitialState.velocity = 0.f;
loc->state.taxiCarInitialState.steeringAngle = 0.f;
loc->state.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
loc->state.taxiCar = loc->state.taxiCarInitialState;
LocationState& locationState = *authoritative.location;
locationState.taxiCarTriggerPosition = taxi.triggerPosition;
locationState.taxiCarWaypoints = taxi.waypoints;
locationState.taxiCarInitialState.position = taxi.initialPosition;
locationState.taxiCarInitialState.rotation = taxi.initialRotation;
locationState.taxiCarInitialState.mode = NpcCar::Mode::NONE;
locationState.taxiCarInitialState.velocity = 0.f;
locationState.taxiCarInitialState.steeringAngle = 0.f;
locationState.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
locationState.taxiCar.waypointReachRadius = taxi.waypointReachRadius;
if (!authoritative.hasPersistentState)
authoritative.resetTaxi();
}
return loc;
@@ -866,24 +886,30 @@ namespace FRG
if (loc->player) {
// Raw pointer: the player belongs to the location (and outlives it), so a
// shared_ptr here would keep unloaded locations alive. Rewired on every load.
loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) {
loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) {
menuManager.updateHealthBar(hp, maxHp);
if (attackerIndex != CharacterState::kNoTarget) {
if (!attackerId.empty()) {
//Someone attacked, cancel dialogue and agro the player
if (loc->dialogueSystem.isDialogueActive()) {
loc->dialogueSystem.stopDialogue();
}
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
((loc->player->state.attackTargetIndex >= 0) &&
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
bool currentTargetDead = false;
for (const auto& npc : loc->npcs) {
if (npc && npc->entityId() == loc->player->characterState.attackTargetId) {
currentTargetDead = npc->characterState.hp <= 0.f;
break;
}
}
if ((loc->player->characterState.attackTargetId.empty() || currentTargetDead)
&&
loc->player->isMoving() == false
&&
loc->player->getHp() > 0) {
loc->player->state.attackTargetIndex = attackerIndex;
internalGameServer.submit(SetCharacterTargetCommand{
loc->locationId(), "player", attackerId});
}
}
@@ -905,7 +931,7 @@ namespace FRG
CheckGlError(__FILE__, __LINE__);
logger() << "Shadow map initialized" << std::endl;
for (auto& [name, loc] : gameState.locations) loc->shadowMap = shadowMap;
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap = shadowMap;
}
void Game::destroyShadowMapAndProparateToLocations()
@@ -915,7 +941,7 @@ namespace FRG
shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
for (auto& [name, loc] : gameState.locations) loc->shadowMap.reset();
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap.reset();
CheckGlError(__FILE__, __LINE__);
}
@@ -1177,7 +1203,9 @@ namespace FRG
if (loadingCompleted && currentLocation())
{
currentLocation()->update(delta);
// The authoritative implementation advances simulation; a future
// network client can use the same call to poll/apply server updates.
internalGameServer.update(delta);
}
}
@@ -1228,6 +1256,10 @@ namespace FRG
}
void Game::update() {
// Host systems (locations, scripts and loading) still execute in-process.
// Publish any changes they made since the previous frame as a new immutable
// revision before presentation clients query the server.
internalGameServer.markHostMutation();
SDL_Event event;
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
while (SDL_PollEvent(&event)) {
@@ -1332,8 +1364,9 @@ namespace FRG
}
else if (event.button.button == SDL_BUTTON_RIGHT
&& event.type == SDL_MOUSEBUTTONUP
&& editorMode == EditorMode::Navigation
&& currentLocation()) {
&& currentLocation()
&& currentLocation()->editorMode == EditorMode::Navigation
) {
currentLocation()->editor.handleRightClick();
}
}
@@ -1378,15 +1411,32 @@ namespace FRG
}
if (event.key.keysym.sym == SDLK_1)
{
startNightTransition();
currentLocation()->switchNavigation(0);
//startNightTransition();
}
if (event.key.keysym.sym == SDLK_2)
{
startDarklandsTransition();
currentLocation()->switchNavigation(1);
//startDarklandsTransition();
}
if (event.key.keysym.sym == SDLK_3)
{
logger() << "Player position: " << currentLocation()->player->state.position.transpose() << std::endl;
currentLocation()->switchNavigation(2);
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
if (event.key.keysym.sym == SDLK_n)
{
if (currentLocation()->editorMode == EditorMode::None)
{
currentLocation()->editorMode = EditorMode::Navigation;
}
else
{
currentLocation()->editorMode = EditorMode::None;
}
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
}
}
@@ -1438,12 +1488,18 @@ namespace FRG
if (!currentLocation()) return;
if (currentLocation()->player) {
currentLocation()->player->state.slowMoTimeRemaining = currentLocation()->player->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*currentLocation()->player, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
for (auto& npc : currentLocation()->npcs) {
if (npc) {
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
currentLocation()->updateCharacterState(
*npc, [](CharacterState& workingState) {
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
});
}
}
}
@@ -1816,10 +1872,11 @@ namespace FRG
if (content.empty()) return {};
try {
nlohmann::json root = nlohmann::json::parse(content);
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {};
SaveSlotInfo info;
info.empty = false;
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
info.locationName = root.value("currentLocationName", "");
info.campaignId = root.at("campaignId").get<std::string>();
info.locationName = root.at("currentLocationId").get<std::string>();
info.savedAt = root.value("savedAt", "");
return info;
} catch (...) {
@@ -1830,7 +1887,7 @@ namespace FRG
void Game::saveGame(int slot)
{
nlohmann::json root;
gameState.save(root);
internalGameServer.save(root);
std::string path = "save_slot" + std::to_string(slot) + ".json";
/*std::ofstream file(path);
@@ -1858,11 +1915,19 @@ namespace FRG
return;
}
// The save's campaign and location may not be loaded yet; the state is applied once they are.
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
const std::string locationId = root.value("currentLocationName", std::string());
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) {
std::cerr << "[save] Unsupported save schema in " << path << std::endl;
return;
}
const std::string campaignId = root.value("campaignId", std::string());
const std::string locationId = root.value("currentLocationId", std::string());
const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId);
if (!campaign || !campaign->findLocation(locationId)) {
std::cerr << "[save] Unknown campaign/location in " << path << std::endl;
return;
}
enterLocationThen(campaignId, locationId, [this, root]() {
gameState.load(root);
internalGameServer.load(root);
playMusicForLoadedGame();
menuManager.enterGameplay(true);
});
+15 -8
View File
@@ -22,6 +22,7 @@
#include "Location.h"
#include "AudioPlayerAsync.h"
#include "GameState.h"
#include "game_server/InternalGameServer.h"
namespace FRG {
struct LocationConfig;
@@ -73,9 +74,7 @@ namespace FRG {
// content reload, don't have a meaningful denominator, so the bar is hidden.
bool showLoadingProgressBar = true;
GameState gameState;
EditorMode editorMode = EditorMode::None;
//EditorMode editorMode = EditorMode::None;
// Returns false if a transition is already in progress.
bool startDarklandsTransition();
@@ -83,6 +82,16 @@ namespace FRG {
std::unique_ptr<AudioPlayerAsync> audioPlayer;
private:
InternalGameServer internalGameServer;
// Host-only alias while location/script orchestration is moved behind typed
// commands. The referenced object is owned by internalGameServer.
GameState& gameState;
public:
IGameServer& server() { return internalGameServer; }
const IGameServer& server() const { return internalGameServer; }
MenuManager menuManager;
void activateSlowMoEffect();
@@ -128,10 +137,8 @@ namespace FRG {
void loadGame(int slot);
void playMusicForLoadedGame();
SaveSlotInfo readSlotInfo(int slot) const;
// Loading model: one campaign at a time, and within it only the location the player
// is in (gameState.locations). Leaving a location stores its state in
// gameState.storedLocationStates and releases it; the player is created once per
// campaign and shared by its locations.
// Loading model: one campaign at a time. InternalGameServer privately owns the
// one live Location while GameState retains pure value state for every location.
std::string loadedCampaignId;
std::shared_ptr<Character> player;
@@ -146,7 +153,7 @@ namespace FRG {
void unloadCampaign();
// Builds the campaign's location and restores its stored state, if any.
void loadLocation(const std::string& locationId);
// Releases the loaded location, keeping its state in storedLocationStates if asked.
// Releases the loaded runtime location, retaining its value state when requested.
void unloadLocation(bool storeState);
// Teleport zones: moves the player to another location of the campaign.
void teleportPlayer(const std::string& destName, const Eigen::Vector3f& destPos, float destRotY);
+70 -44
View File
@@ -2,23 +2,10 @@
#include <ctime>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace FRG {
// Saves made before the tutorial rework kept the dorm and the university tutorials
// as two state machines (tutorialStep 0..6 + uniIntTutorialState 0..4); map them
// onto the single TutorialStep sequence.
static TutorialStep migrateLegacyTutorialStep(int dormStep, int uniIntState)
{
if (dormStep <= 4) return static_cast<TutorialStep>(dormStep); // Step0..Step4 kept their values
switch (uniIntState) {
case 2: return TutorialStep::DarklandsHealth; // DarklandsActive
case 3: return TutorialStep::DarklandsAttack; // DarklandsStep13
case 4: return TutorialStep::Completed; // DarklandsFull
default: return TutorialStep::EnterDarklands; // Step10 / Step11
}
}
bool GameState::completeTutorialStep(TutorialStep step)
{
if (tutorialStep != step || step == TutorialStep::Completed) return false;
@@ -26,8 +13,29 @@ bool GameState::completeTutorialStep(TutorialStep step)
return true;
}
CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId)
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
const CharacterState* GameState::findCharacter(
const std::string& locationId, const std::string& characterId) const
{
auto player = players.find(characterId);
if (player != players.end() && player->second) return player->second.get();
auto location = locations.find(locationId);
return location == locations.end()
? nullptr : location->second.findCharacter(characterId, players);
}
void GameState::save(nlohmann::json& out) const
{
out["schemaVersion"] = kSchemaVersion;
// Timestamp
std::time_t now = std::time(nullptr);
std::tm* tm = std::localtime(&now);
@@ -37,7 +45,7 @@ void GameState::save(nlohmann::json& out) const
// --- World ---
out["campaignId"] = campaignId;
out["currentLocationName"] = currentLocationName;
out["currentLocationId"] = currentLocationName;
out["isDarklands"] = isDarklands;
out["isNight"] = isNight;
out["isDawn"] = isDawn;
@@ -87,16 +95,21 @@ void GameState::save(nlohmann::json& out) const
questJournal.save(questJson);
out["questJournal"] = std::move(questJson);
// --- Players ---
out["players"] = nlohmann::json::object();
for (const auto& [id, player] : players) {
if (!player) continue;
nlohmann::json playerJson;
player->save(playerJson);
out["players"][id] = std::move(playerJson);
}
// --- Locations ---
// Unloaded locations keep their last stored state; loaded ones are saved live.
nlohmann::json locsJson = nlohmann::json::object();
for (const auto& [name, stored] : storedLocationStates) locsJson[name] = stored;
for (const auto& [name, loc] : locations) {
if (loc) {
nlohmann::json locJson;
loc->save(locJson);
locsJson[name] = std::move(locJson);
}
for (const auto& [name, location] : locations) {
nlohmann::json locJson;
location.save(locJson);
locsJson[name] = std::move(locJson);
}
out["locations"] = std::move(locsJson);
@@ -105,12 +118,16 @@ void GameState::save(nlohmann::json& out) const
void GameState::load(const nlohmann::json& in)
{
const int schemaVersion = in.value("schemaVersion", -1);
if (schemaVersion != kSchemaVersion)
throw std::runtime_error(
"Unsupported game-state schema version: " +
std::to_string(schemaVersion));
//logger() << "GameState::load step 1" << std::endl;
// --- World ---
// Literal default: saves made before campaigns existed belong to the main game.
campaignId = in.value("campaignId", std::string("main"));
currentLocationName = in.value("currentLocationName", currentLocationName);
campaignId = in.at("campaignId").get<std::string>();
currentLocationName = in.at("currentLocationId").get<std::string>();
isDarklands = in.value("isDarklands", isDarklands);
isNight = in.value("isNight", isNight);
isDawn = in.value("isDawn", isDawn);
@@ -142,11 +159,8 @@ void GameState::load(const nlohmann::json& in)
//logger() << "GameState::load step 5" << std::endl;
// --- Tutorial ---
if (in.contains("uniIntTutorialState")) {
tutorialStep = migrateLegacyTutorialStep(in.value("tutorialStep", 0), in["uniIntTutorialState"].get<int>());
} else {
tutorialStep = static_cast<TutorialStep>(in.value("tutorialStep", static_cast<int>(tutorialStep)));
}
tutorialStep = static_cast<TutorialStep>(
in.at("tutorialStep").get<int>());
tutorialPhoneChatScreenOpened = in.value("tutorialPhoneChatScreenOpened", tutorialPhoneChatScreenOpened);
tutorialJournalScreenOpened = in.value("tutorialJournalScreenOpened", tutorialJournalScreenOpened);
tutorialNeedOpenTaxiScreen = in.value("tutorialNeedOpenTaxiScreen", tutorialNeedOpenTaxiScreen);
@@ -193,20 +207,32 @@ void GameState::load(const nlohmann::json& in)
logger() << "GameState::load step 10" << std::endl;
// --- Locations ---
// Every location's state is stored (applied when the location loads); the ones
// already loaded get it right away.
storedLocationStates.clear();
if (in.contains("locations") && in["locations"].is_object()) {
for (const auto& [name, locJson] : in["locations"].items()) {
//logger() << "GameState::load step 10.1: " << name << std::endl;
storedLocationStates[name] = locJson;
// --- Players ---
const auto& playerValues = in.at("players");
if (!playerValues.is_object() || !playerValues.contains("player"))
throw std::runtime_error("Game state requires players.player");
for (const auto& [id, value] : playerValues.items()) {
auto& player = players[id];
if (!player) player = std::make_shared<CharacterState>();
player->load(value);
}
for (auto it = players.begin(); it != players.end();) {
if (!playerValues.contains(it->first)) it = players.erase(it);
else ++it;
}
auto it = locations.find(name);
if (it != locations.end() && it->second) {
it->second->load(locJson);
}
}
// --- Locations ---
const auto& locationValues = in.at("locations");
if (!locationValues.is_object() ||
!locationValues.contains(currentLocationName))
throw std::runtime_error(
"Game state does not contain current location '" +
currentLocationName + "'");
for (const auto& [id, value] : locationValues.items())
locations[id].load(value);
for (auto it = locations.begin(); it != locations.end();) {
if (!locationValues.contains(it->first)) it = locations.erase(it);
else ++it;
}
//logger() << "GameState::load step 11" << std::endl;
+13 -8
View File
@@ -1,7 +1,7 @@
#pragma once
#include "items/Item.h"
#include "quest/QuestJournal.h"
#include "Location.h"
#include "LocationGameState.h"
#include "ISaveable.h"
#include "external/nlohmann/json.hpp"
#include <unordered_map>
@@ -32,17 +32,17 @@ struct StoredChatMessage {
bool incoming;
};
using LocationMap = std::unordered_map<std::string, std::shared_ptr<Location>>;
struct GameState : public ISaveable {
static constexpr int kSchemaVersion = 1;
// --- World ---
// Campaign being played (see CampaignRegistry).
std::string campaignId = "main";
// The campaign's loaded location(s); only the one the player is in is kept loaded.
LocationMap locations;
// Saved state (Location::save) of the campaign's locations that are not loaded,
// applied when one is loaded again. save()/load() cover both maps.
std::unordered_map<std::string, nlohmann::json> storedLocationStates;
// Pure authoritative location data. Live Location/render/script instances are
// private runtime storage in InternalGameServer and never enter GameState.
std::unordered_map<std::string, LocationGameState> locations;
// Players are campaign-level entities and remain stable across locations.
PlayerStateMap players;
std::string currentLocationName;
bool isDarklands = false;
bool isNight = false;
@@ -73,6 +73,11 @@ struct GameState : public ISaveable {
// Moves to the next step if `step` is the current one; returns whether it did.
bool completeTutorialStep(TutorialStep step);
CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId);
const CharacterState* findCharacter(
const std::string& locationId, const std::string& characterId) const;
// --- Phone / chat ---
bool chatUnread[3] = { true, true, true };
std::string chatPreviewMsg[3];
+290 -783
View File
File diff suppressed because it is too large. Load diff
+37 -42
View File
@@ -16,13 +16,15 @@
#include "TeleportZone.h"
#include "LocationEditor.h"
#include "LocationState.h"
#include "ISaveable.h"
#include "LocationGameState.h"
#include <functional>
#include <cstdint>
#include <unordered_map>
#include "Character.h"
namespace FRG
{
class IGameServer;
struct PointLight
{
@@ -35,7 +37,7 @@ namespace FRG
// Rectangular limits — keep light confined to a room-sized AABB centred on the light
float limitX = 99999.f; // shader fragment limit: half-width in world X
float limitY = 99999.f; // shader fragment limit: half-width in world Z
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use legacy nearest-light logic
float autoLightLimitX = -1.0f; // activation rect half-width X; <0 = use nearest-light logic
float autoLightLimitY = -1.0f; // activation rect half-width Z
};
@@ -55,7 +57,7 @@ namespace FRG
std::string interactiveObjectsJsonPath;
std::string npcsJsonPath;
std::string dialoguesJsonPath;
std::vector<std::string> navigationJsonPaths;
//std::vector<std::string> navigationJsonPaths;
std::string scriptPath;
std::string teleportsJsonPath;
std::string triggerZonesJsonPath;
@@ -64,17 +66,17 @@ namespace FRG
// Player shared by all locations of a campaign (see Location::createPlayer);
// setup() creates a private one when this is null.
std::shared_ptr<Character> player;
LocationGameState* gameState = nullptr;
};
class Location : public ISaveable
class Location
{
public:
Location(Renderer& iRenderer, Inventory& iInventory);
Location(Renderer& iRenderer, Inventory& iInventory, LocationState& state, IGameServer& server,
std::string locationId);
// Detaches the (shared, longer-lived) player from this location.
~Location();
// ---- All serialisable runtime state ----
LocationState state;
const std::string& locationId() const { return locationId_; }
std::unordered_map<std::string, LoadedGameObject> gameObjects;
@@ -88,10 +90,11 @@ namespace FRG
std::vector<std::unique_ptr<Character>> npcs;
// Builds the player character (models, animations, weapon, textures).
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer);
static std::shared_ptr<Character> createPlayer(Renderer& renderer, AudioPlayerAsync* audioPlayer,
CharacterState& state, IGameServer& server);
std::vector<PathFinder> navigationMaps;
PathFinder* navigation = nullptr;
//std::vector<PathFinder> navigationMaps;
//int state.activeNavigationIndex = 0;
std::shared_ptr<ShadowMap> shadowMap;
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
@@ -114,10 +117,6 @@ namespace FRG
std::function<void()> onPlayerTaxiRequired;
// Optional, set by Game. While it returns true the player can't pick up /
// use interactive objects (the dorm tutorial unlocks them step by step).
std::function<bool()> areInteractiveObjectsLocked;
// Set by Game after setup(). Lua and onDeathAnimComplete call this to
// request the transition without coupling Location to Game directly.
std::function<bool()> requestDarklandsTransition;
@@ -140,7 +139,7 @@ namespace FRG
EditorMode editorMode = EditorMode::None;
LocationEditor editor;
std::vector<std::string> navigationMapPaths;
//std::vector<std::string> navigationMapPaths;
// Set by Game when the user's primary pointer (left mouse / single touch)
// has crossed the tap-vs-drag threshold and is now rotating the camera.
@@ -162,8 +161,20 @@ namespace FRG
void setup(const LocationSetup& params, Quest::QuestJournal* journal, AudioPlayerAsync* audioPlayer);
void setupNavigation(const std::vector<std::string>& paths);
//void setupNavigation(const std::vector<std::string>& paths);
bool switchNavigation(int index);
void setCharacterTarget(Character& character, const Eigen::Vector3f& target,
std::string callbackName = "");
void updateCharacterState(
Character& character,
const std::function<void(CharacterState&)>& mutation);
void clearCharacterAttackTarget(Character& character);
void stopCharacter(Character& character);
void faceCharacter(Character& character, std::string targetId);
void replanCharacter(Character& character);
void resetCharacterAfterDeath(Character& character);
void tryCharacterAttackCombo(Character& character);
void setCharacterHp(Character& character, float hp);
InteractiveObject* raycastInteractiveObjects(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir);
Character* raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance = 100.0f);
@@ -177,8 +188,7 @@ namespace FRG
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
void update(int64_t deltaMs);
void updateNpcCar(int64_t deltaMs);
void update(int64_t deltaMs, const LocationSimulationEvents& events);
// Purely visual feedback for click-triggered player routes. Disabling it
// also cancels an animation that is currently playing.
void setPathVisualizationEnabled(bool enabled);
@@ -194,26 +204,21 @@ namespace FRG
void setDialogueFlag(const std::string& flag, int value);
int getDialogueFlag(const std::string& flag) const;
void onTaxiCalled();
// ---- Save / Load ----
// Serialises LocationState + all Character/InteractiveObject sub-states + Lua globals.
void save(nlohmann::json& out) const override;
// Restores state from JSON written by save.
// Navigation pointer and triggerZone.playerInside are re-synced automatically.
void load(const nlohmann::json& in) override;
protected:
friend class LocationEditor;
friend class Game;
friend class ScriptEngine;
friend class InternalGameServer;
Renderer& renderer;
Inventory& inventory;
AudioPlayerAsync* audioPlayer;
private:
LocationState& state; // non-owning binding to LocationGameState
IGameServer& gameServer_;
std::string locationId_;
// ---- Pointer-to-index helpers ----
InteractiveObject* getTargetInteractiveObject();
TeleportZone* getTargetTeleportZone();
int findInteractiveObjectIndex(const InteractiveObject* obj) const;
int findTeleportZoneIndex(const TeleportZone* tz) const;
enum class InstancedTexture { None, Color, Darklands };
@@ -223,26 +228,16 @@ namespace FRG
void drawInstancedStatics(const std::string& instancedShaderName, InstancedTexture textureKind,
const std::function<void()>& setUniforms = {});
void resolveCharacterCollisions();
void updateDynamicReplans(int64_t deltaMs);
void loadTeleportZones(const std::string& jsonPath, const char* zipFile);
void loadTriggerZones(const std::string& jsonPath, const char* zipFile);
void loadPointLights(const std::string& jsonPath, const char* zipFile);
void updateTriggerZones(const Eigen::Vector3f& playerPos);
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
int findNpcIndex(const Character* npc) const;
void fireBumpCallbacks(Character* mover, Character* standing);
void captureAuxiliaryState(LocationGameState& out) const;
void applyAuxiliaryState(const LocationGameState& in);
PathVisualization pathVisualization;
std::unordered_map<Character*, Eigen::Vector3f> lastCharacterPositions;
std::unordered_map<Character*, int64_t> replanCooldownRemainingMs;
std::unordered_map<Character*, int> stuckFrameCounts;
std::unordered_map<Character*, float> stuckPauseRemainingS;
static constexpr float NPC_BUMP_CALLBACK_COOLDOWN = 5.0f;
std::unordered_map<int, float> npcBumpedByPlayerCooldown;
std::unordered_map<int, float> npcBumpsPlayerCooldown;
};
} // namespace FRG
+20 -16
View File
@@ -24,10 +24,9 @@ namespace FRG
void LocationEditor::buildNavMeshes()
{
navigationEditorNavMeshes.clear();
if (!loc.navigation) return;
const float y = loc.navigation->getFloorY() + 0.02f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
for (const auto& obs : loc.navigation->getObstaclePolygons()) {
for (const auto& obs : loc.state.navigationMaps[loc.state.activeNavigationIndex].getObstaclePolygons()) {
if (obs.polygon.size() < 3) continue;
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
@@ -51,7 +50,7 @@ namespace FRG
{
VertexDataStruct data;
const Eigen::Vector3f yellow(1.0f, 1.0f, 0.0f);
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.05f : 0.05f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.05f;
const float s = 0.2f;
for (const auto& pt : navigationEditorPoints) {
@@ -68,12 +67,19 @@ namespace FRG
}
navigationEditorPointsMesh.data = std::move(data);
navigationEditorPointsMesh.RefreshVBO();
if (navigationEditorPointsMesh.data.PositionData.size() > 0)
{
navigationEditorPointsMesh.RefreshVBO();
}
}
void LocationEditor::drawPoints()
{
if (navigationEditorPoints.empty()) return;
if (navigationEditorPointsMesh.data.PositionData.empty())
{
return;
}
loc.renderer.shaderManager.PushShader("defaultColor");
loc.renderer.SetMatrix();
loc.renderer.DrawVertexRenderStruct(navigationEditorPointsMesh);
@@ -120,12 +126,12 @@ namespace FRG
poly.polygon.emplace_back(pt.x(), pt.z());
}
if (loc.navigation) loc.navigation->addObstaclePolygon(poly);
loc.state.navigationMaps[loc.state.activeNavigationIndex].addObstaclePolygon(poly);
logger() << "[NAV_EDITOR] Added obstacle '" << poly.name << "' with "
<< poly.polygon.size() << " vertices\n";
{
const float y = loc.navigation ? loc.navigation->getFloorY() + 0.02f : 0.02f;
const float y = loc.state.navigationMaps[loc.state.activeNavigationIndex].getFloorY() + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
@@ -147,14 +153,12 @@ namespace FRG
if (!std::filesystem::exists(baseName + ".json")) break;
}
if (!loc.navigation) return;
if (loc.navigation->saveConfig(baseName + ".json"))
if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveConfig(baseName + ".json"))
logger() << "[NAV_EDITOR] Saved config to: " << baseName << ".json\n";
else
std::cerr << "[NAV_EDITOR] Failed to save config to: " << baseName << ".json\n";
if (loc.navigation->saveGrid(baseName + ".txt"))
if (loc.state.navigationMaps[loc.state.activeNavigationIndex].saveGrid(baseName + ".txt"))
logger() << "[NAV_EDITOR] Saved grid to: " << baseName << ".txt\n";
else
std::cerr << "[NAV_EDITOR] Failed to save grid to: " << baseName << ".txt\n";
@@ -162,8 +166,8 @@ namespace FRG
void LocationEditor::reload()
{
loc.setupNavigation(loc.navigationMapPaths);
logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
//loc.setupNavigation(loc.navigationMapPaths);
//logger() << "[NAV_EDITOR] Reloaded navigation maps (" << loc.navigationMapPaths.size() << " maps)\n";
}
static VertexDataStruct CreateBoundsBoxMesh(const Eigen::Vector3f corners[8], const Eigen::Vector3f& color)
@@ -454,9 +458,9 @@ namespace FRG
data.rotationX = 0.0f;
data.rotationY = rotDist(rng);
data.rotationZ = 0.0f;
data.positionX = loc.player->state.position.x();
data.positionY = loc.player->state.position.y();//-0.1;
data.positionZ = loc.player->state.position.z();
data.positionX = loc.player->characterState.position.x();
data.positionY = loc.player->characterState.position.y();//-0.1;
data.positionZ = loc.player->characterState.position.z();
data.scale = scaleDist(rng);
GameObjectData data2;
+351
View File
@@ -0,0 +1,351 @@
#include "LocationGameState.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace FRG {
namespace {
constexpr float PI = 3.14159265358979323846f;
constexpr float NPC_TALK_DISTANCE = 1.35f;
}
CharacterState* LocationGameState::findCharacter(
const std::string& id, PlayerStateMap& players)
{
if (id.empty()) return nullptr;
auto entity = players.find(id);
if (entity != players.end() && entity->second) return entity->second.get();
auto npc = npcs.find(id);
if (npc != npcs.end() && npc->second) return npc->second.get();
return nullptr;
}
const CharacterState* LocationGameState::findCharacter(
const std::string& id, const PlayerStateMap& players) const
{
if (id.empty()) return nullptr;
auto entity = players.find(id);
if (entity != players.end() && entity->second) return entity->second.get();
auto npc = npcs.find(id);
if (npc != npcs.end() && npc->second) return npc->second.get();
return nullptr;
}
void LocationGameState::startTaxi()
{
location->taxiCar.waypoints = location->taxiCarWaypoints;
location->taxiCar.currentWaypoint = 0;
location->taxiCar.mode = NpcCar::Mode::FOLLOW_WAYPOINTS;
}
void LocationGameState::resetTaxi()
{
location->taxiCar = location->taxiCarInitialState;
location->taxiArrivedCallbackCalled = false;
}
bool LocationGameState::updateNpcCar(std::int64_t deltaMs)
{
const float dt = static_cast<float>(deltaMs) / 1000.0f;
NpcCar& car = location->taxiCar;
Eigen::Vector3f target = car.position;
float throttle = 0.f;
bool hasTarget = false;
bool waypointsOver = false;
if (car.mode == NpcCar::Mode::FOLLOW_WAYPOINTS) {
if (!car.waypoints.empty()) {
if (car.currentWaypoint >= car.waypoints.size())
car.currentWaypoint = 0;
Eigen::Vector3f toTarget =
car.waypoints[car.currentWaypoint] - car.position;
toTarget.y() = 0.f;
if (toTarget.norm() < car.waypointReachRadius) {
++car.currentWaypoint;
if (car.currentWaypoint == car.waypoints.size())
waypointsOver = true;
}
if (waypointsOver) {
car.mode = NpcCar::Mode::NONE;
} else {
target = car.waypoints[car.currentWaypoint];
throttle = 1.f;
hasTarget = true;
}
}
}
float targetHeading = car.rotation;
if (hasTarget) {
Eigen::Vector3f toTarget = target - car.position;
toTarget.y() = 0.f;
if (toTarget.squaredNorm() > 1e-4f)
targetHeading = std::atan2(-toTarget.x(), -toTarget.z());
}
float angleDiff = targetHeading - car.rotation;
while (angleDiff > PI) angleDiff -= 2.f * PI;
while (angleDiff < -PI) angleDiff += 2.f * PI;
const float turnIntent = (std::max)(-1.f, (std::min)(1.f, angleDiff * 2.f));
const float targetSteer = turnIntent * NpcCar::maxSteerAngle;
const float steerLerp = (std::min)(1.f, dt * 8.f);
car.steeringAngle += (targetSteer - car.steeringAngle) * steerLerp;
if (throttle > 0.f) {
car.velocity += NpcCar::acceleration * dt * throttle;
} else {
const float resistance = NpcCar::friction * dt;
if (car.velocity > 0.f)
car.velocity = (std::max)(0.f, car.velocity - resistance);
else if (car.velocity < 0.f)
car.velocity = (std::min)(0.f, car.velocity + resistance);
}
car.velocity = (std::max)(
-NpcCar::maxReverseSpeed, (std::min)(NpcCar::maxSpeed, car.velocity));
if (std::abs(car.velocity) > 0.01f) {
const float speedFactor = car.velocity / NpcCar::maxSpeed;
car.rotation += turnIntent * NpcCar::turnRate * dt * speedFactor;
}
const Eigen::Vector3f forward(
-std::sin(car.rotation), 0.f, -std::cos(car.rotation));
const Eigen::Vector3f totalDelta = forward * car.velocity * dt;
const float totalDist = totalDelta.norm();
constexpr float maxSubstep = 0.2f;
const int substeps = (std::max)(
1, static_cast<int>(std::ceil(totalDist / maxSubstep)));
const Eigen::Vector3f stepDelta = totalDelta / static_cast<float>(substeps);
for (int step = 0; step < substeps; ++step)
car.position += stepDelta;
car.wheelAngle += car.velocity * 4.f * dt;
while (car.wheelAngle > 2.f * PI)
car.wheelAngle -= 2.f * PI;
if (!location->taxiArrivedCallbackCalled) {
const Eigen::Vector3f toArrival =
car.position - location->taxiCarTriggerPosition;
if (toArrival.squaredNorm() < 9.f) {
location->taxiArrivedCallbackCalled = true;
return true;
}
}
return false;
}
LocationSimulationEvents LocationGameState::update(
std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked)
{
LocationSimulationEvents result;
result.taxiArrived = updateNpcCar(deltaMs);
std::vector<std::string> objectIds;
objectIds.reserve(interactiveObjects.size());
for (const auto& [id, object] : interactiveObjects)
if (object) objectIds.push_back(id);
std::sort(objectIds.begin(), objectIds.end());
for (const std::string& id : objectIds) {
InteractiveObjectUpdateEvents events =
interactiveObjects.at(id)->update(deltaMs);
if (events.onCompleteName.empty()) continue;
result.interactiveObjects.push_back(
{id, std::move(events.onCompleteName), false, {}});
}
// All characters observe the same tick-start state. Damage is applied only
// after every state has advanced, so vector order cannot suppress an attack.
std::unordered_map<std::string, CharacterObservation> observations;
for (const auto& [id, player] : players)
if (player)
observations.emplace(
id, CharacterObservation{player->position, player->hp});
for (const auto& [id, npc] : npcs)
if (npc)
observations.emplace(
id, CharacterObservation{npc->position, npc->hp});
auto advance = [&](const std::string& id, CharacterState& state) {
CharacterUpdateContext context;
auto attack = observations.find(state.attackTargetId);
if (attack != observations.end()) context.attackTarget = &attack->second;
auto face = observations.find(state.faceTargetId);
if (face != observations.end()) context.faceTarget = &face->second;
CharacterUpdateEvents events = state.update(deltaMs, context);
if (!events.arrivedCallbackName.empty() || events.attackImpact ||
events.deathAnimationComplete || events.navigationRequested) {
result.characters.push_back({id, std::move(events)});
}
};
std::vector<std::string> playerIds;
playerIds.reserve(players.size());
for (const auto& [id, player] : players)
if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
for (const std::string& id : playerIds)
advance(id, *players.at(id));
std::vector<std::string> npcIds;
npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs)
if (npc) npcIds.push_back(id);
std::sort(npcIds.begin(), npcIds.end());
for (const std::string& id : npcIds)
advance(id, *npcs.at(id));
for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue;
CharacterState* target = findCharacter(source.events.attackTargetId, players);
if (!target) continue;
constexpr float damage = 10.f;
target->setHp(target->hp - damage);
result.damage.push_back({
source.events.attackTargetId,
source.characterId,
damage,
target->hp,
target->initialHp,
source.events.attackDirection});
}
auto player = players.find("player");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second) {
auto target = interactiveObjects.find(location->targetInteractiveObjectId);
const auto object =
target == interactiveObjects.end() ? nullptr : target->second;
if (object && !object->isAnimating) {
const Eigen::Vector3f& approachTarget = object->hasInteractionPosition
? object->interactionPosition : object->position;
if ((player->second->position - approachTarget).norm() <= object->approachRadius) {
result.interactiveObjects.push_back(
{location->targetInteractiveObjectId, {}, true,
object->activateFunctionName});
location->targetInteractiveObjectId.clear();
}
}
}
if (!location->targetInteractNpcId.empty() &&
player != players.end() && player->second &&
npcs.count(location->targetInteractNpcId) != 0 &&
npcs.at(location->targetInteractNpcId)) {
const auto& npc = npcs.at(location->targetInteractNpcId);
const float distance =
(player->second->position - npc->position).norm();
if (distance <= NPC_TALK_DISTANCE) {
player->second->stopInPlace();
npc->faceTargetId = "player";
result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear();
}
}
return result;
}
void LocationGameState::save(nlohmann::json& out) const
{
location->save(out["state"]);
out["npcs"] = nlohmann::json::object();
for (const auto& [id, npc] : npcs) {
if (npc) {
nlohmann::json value;
npc->save(value);
out["npcs"][id] = std::move(value);
}
}
out["interactiveObjects"] = nlohmann::json::object();
for (const auto& [id, object] : interactiveObjects) {
if (!object) continue;
nlohmann::json value;
object->save(value);
out["interactiveObjects"][id] = std::move(value);
}
out["dialogue"] = dialogue;
out["scriptGlobals"] = scriptGlobals;
out["triggerZones"] = nlohmann::json::object();
for (const auto& [id, zone] : triggerZones)
out["triggerZones"][id] = {{"enabled", zone.enabled}, {"playerInside", zone.playerInside}};
out["teleportZones"] = nlohmann::json::object();
for (const auto& [id, active] : teleportZones)
out["teleportZones"][id] = active;
}
void LocationGameState::load(const nlohmann::json& in)
{
hasPersistentState = true;
if (!location) location = std::make_shared<LocationState>();
location->load(in.at("state"));
const auto& npcValues = in.at("npcs");
if (!npcValues.is_object())
throw std::runtime_error("Location npcs must be an object");
{
const auto& values = npcValues;
if (!npcs.empty()) {
for (const auto& [id, npc] : npcs)
if (!values.contains(id))
throw std::runtime_error(
"Saved location is missing NPC '" + id + "'");
for (const auto& [id, value] : values.items())
if (npcs.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown NPC '" + id + "'");
}
for (const auto& [id, value] : values.items()) {
auto& npc = npcs[id];
if (!npc) npc = std::make_shared<CharacterState>();
npc->load(value);
}
}
const auto& objectValues = in.at("interactiveObjects");
if (!objectValues.is_object())
throw std::runtime_error("Location interactiveObjects must be an object");
{
const auto& values = objectValues;
if (!interactiveObjects.empty()) {
for (const auto& [id, object] : interactiveObjects)
if (!values.contains(id))
throw std::runtime_error(
"Saved location is missing interactive object '" + id + "'");
for (const auto& [id, value] : values.items())
if (interactiveObjects.count(id) == 0)
throw std::runtime_error(
"Saved location contains unknown interactive object '" + id + "'");
}
for (const auto& [id, value] : values.items()) {
auto& object = interactiveObjects[id];
if (!object) object = std::make_shared<InteractiveObjectState>();
object->load(value);
}
}
dialogue = in.at("dialogue");
scriptGlobals = in.at("scriptGlobals");
triggerZones.clear();
const auto& triggerValues = in.at("triggerZones");
if (!triggerValues.is_object())
throw std::runtime_error("Location triggerZones must be an object");
for (const auto& [id, value] : triggerValues.items())
triggerZones[id] = {
value.at("enabled").get<bool>(),
value.at("playerInside").get<bool>()};
teleportZones.clear();
const auto& teleportValues = in.at("teleportZones");
if (!teleportValues.is_object())
throw std::runtime_error("Location teleportZones must be an object");
for (const auto& [id, value] : teleportValues.items())
teleportZones[id] = value.get<bool>();
}
} // namespace FRG
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "CharacterState.h"
#include "LocationState.h"
#include "items/InteractiveObjectState.h"
#include "external/nlohmann/json.hpp"
#include <string>
#include <unordered_map>
#include <vector>
namespace FRG {
struct TriggerZoneGameState {
bool enabled = true;
bool playerInside = false;
};
struct CharacterSimulationEvent {
std::string characterId;
CharacterUpdateEvents events;
};
struct CharacterDamageEvent {
std::string targetId;
std::string attackerId;
float amount = 0.f;
float hp = 0.f;
float maxHp = 0.f;
Eigen::Vector3f direction = Eigen::Vector3f::Zero();
};
struct InteractiveObjectSimulationEvent {
std::string objectId;
std::string animationCompleteFunctionName;
bool activated = false;
std::string activateFunctionName;
};
struct LocationSimulationEvents {
std::vector<CharacterSimulationEvent> characters;
std::vector<CharacterDamageEvent> damage;
std::vector<InteractiveObjectSimulationEvent> interactiveObjects;
std::string npcInteractionId;
bool taxiArrived = false;
};
// Pure authoritative state for one location. Shared ownership gives bound local
// simulation objects stable addresses; runtime renderers and Location pointers
// are deliberately absent, and public clients receive value-only snapshots.
struct LocationGameState {
std::shared_ptr<LocationState> location = std::make_shared<LocationState>();
std::unordered_map<std::string, std::shared_ptr<CharacterState>> npcs;
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>> interactiveObjects;
bool lockInteractiveObjectsDuringTutorial = false;
bool hasPersistentState = false;
std::unordered_map<std::string, TriggerZoneGameState> triggerZones;
std::unordered_map<std::string, bool> teleportZones;
nlohmann::json dialogue = nlohmann::json::object();
nlohmann::json scriptGlobals = nlohmann::json::object();
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const;
void startTaxi();
void resetTaxi();
LocationSimulationEvents update(
std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked);
void save(nlohmann::json& out) const;
void load(const nlohmann::json& in);
private:
bool updateNpcCar(std::int64_t deltaMs);
};
} // namespace FRG
+83 -206
View File
@@ -2,6 +2,76 @@
namespace FRG {
extern const char* CONST_ZIP_FILE;
namespace {
nlohmann::json saveCar(const NpcCar& car)
{
nlohmann::json waypoints = nlohmann::json::array();
for (const auto& waypoint : car.waypoints)
waypoints.push_back(
{{"x", waypoint.x()}, {"y", waypoint.y()}, {"z", waypoint.z()}});
return {
{"position", {car.position.x(), car.position.y(), car.position.z()}},
{"rotation", car.rotation},
{"velocity", car.velocity},
{"steeringAngle", car.steeringAngle},
{"wheelAngle", car.wheelAngle},
{"mode", static_cast<int>(car.mode)},
{"waypoints", std::move(waypoints)},
{"currentWaypoint", car.currentWaypoint}
};
}
void loadCar(NpcCar& car, const nlohmann::json& value)
{
const auto& position = value.at("position");
car.position = {
position.at(0).get<float>(),
position.at(1).get<float>(),
position.at(2).get<float>()};
car.rotation = value.at("rotation").get<float>();
car.velocity = value.at("velocity").get<float>();
car.steeringAngle = value.at("steeringAngle").get<float>();
car.wheelAngle = value.at("wheelAngle").get<float>();
car.mode = static_cast<NpcCar::Mode>(value.at("mode").get<int>());
car.waypoints.clear();
for (const auto& waypoint : value.at("waypoints"))
car.waypoints.push_back({
waypoint.at("x").get<float>(),
waypoint.at("y").get<float>(),
waypoint.at("z").get<float>()});
car.currentWaypoint = value.at("currentWaypoint").get<std::size_t>();
}
}
std::vector<Eigen::Vector3f> LocationState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const
{
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {});
}
void LocationState::initNavigation(const std::vector<std::string>& paths)
{
const int savedActiveNavigationIndex = activeNavigationIndex;
//navigationMapPaths = paths;
navigationMaps.clear();
navigationMaps.resize(paths.size());
for (size_t i = 0; i < paths.size(); ++i) {
// Service the OS message queue between builds so the window doesn't
// get flagged as "not responding" while loading many navigation grids.
SDL_PumpEvents();
navigationMaps[i].build(paths[i], CONST_ZIP_FILE);
}
activeNavigationIndex =
savedActiveNavigationIndex >= 0 &&
savedActiveNavigationIndex < static_cast<int>(navigationMaps.size())
? savedActiveNavigationIndex
: 0;
}
void LocationState::save(nlohmann::json& out) const
{
out["cameraAzimuth"] = cameraAzimuth;
@@ -9,99 +79,14 @@ void LocationState::save(nlohmann::json& out) const
out["activeNavigationIndex"] = activeNavigationIndex;
out["targetInteractiveObjectIndex"] = targetInteractiveObjectIndex;
out["targetInteractNpcIndex"] = targetInteractNpcIndex;
out["targetTeleportZoneIndex"] = targetTeleportZoneIndex;
out["targetInteractiveObjectId"] = targetInteractiveObjectId;
out["targetInteractNpcId"] = targetInteractNpcId;
out["targetTeleportZoneId"] = targetTeleportZoneId;
out["isDarklands"] = isDarklands;
out["isNight"] = isNight;
out["isDawn"] = isDawn;
// Taxi stuff:
nlohmann::json taxiJson;
taxiJson["taxiCar"] = {
{"positionX", taxiCar.position.x()},
{"positionY", taxiCar.position.y()},
{"positionZ", taxiCar.position.z()},
{"rotation", taxiCar.rotation},
{"velocity", taxiCar.velocity},
{"steeringAngle", taxiCar.steeringAngle},
{"mode", static_cast<int>(taxiCar.mode)},
{"waypoints", nlohmann::json::array()},
{"currentWaypoint", taxiCar.currentWaypoint},
{"waypointReachRadius", taxiCar.waypointReachRadius}
out["taxi"] = {
{"car", saveCar(taxiCar)},
{"arrivedCallbackCalled", taxiArrivedCallbackCalled}
};
nlohmann::json taxiWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCar.waypoints.size(); ++i) {
taxiWaypoints.push_back({
{"x", taxiCar.waypoints[i].x()},
{"y", taxiCar.waypoints[i].y()},
{"z", taxiCar.waypoints[i].z()}
});
}
taxiJson["taxiCar"]["waypoints"] = std::move(taxiWaypoints);
taxiJson["taxiCarInitialState"] = {
{"positionX", taxiCarInitialState.position.x()},
{"positionY", taxiCarInitialState.position.y()},
{"positionZ", taxiCarInitialState.position.z()},
{"rotation", taxiCarInitialState.rotation},
{"velocity", taxiCarInitialState.velocity},
{"steeringAngle", taxiCarInitialState.steeringAngle},
{"mode", static_cast<int>(taxiCarInitialState.mode)},
{"waypoints", nlohmann::json::array()},
{"currentWaypoint", taxiCarInitialState.currentWaypoint},
{"waypointReachRadius", taxiCarInitialState.waypointReachRadius}
};
nlohmann::json taxiInitialWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCarInitialState.waypoints.size(); ++i) {
taxiInitialWaypoints.push_back({
{"x", taxiCarInitialState.waypoints[i].x()},
{"y", taxiCarInitialState.waypoints[i].y()},
{"z", taxiCarInitialState.waypoints[i].z()}
});
}
taxiJson["taxiCarInitialState"]["waypoints"] = std::move(taxiInitialWaypoints);
nlohmann::json taxiDefaultWaypoints = nlohmann::json::array();
for (int i = 0; i < taxiCarWaypoints.size(); ++i) {
taxiDefaultWaypoints.push_back({
{"x", taxiCarWaypoints[i].x()},
{"y", taxiCarWaypoints[i].y()},
{"z", taxiCarWaypoints[i].z()}
});
}
taxiJson["taxiDefaultWaypoints"] = taxiDefaultWaypoints;
/* taxiCarTriggerPosition = Eigen::Vector3f(
taxiJson.value("taxiCarTriggerPositionX", 0.f),
taxiJson.value("taxiCarTriggerPositionY", 0.f),
taxiJson.value("taxiCarTriggerPositionZ", 0.f)
);*/
taxiJson["taxiCarTriggerPositionX"] = taxiCarTriggerPosition.x();
taxiJson["taxiCarTriggerPositionY"] = taxiCarTriggerPosition.y();
taxiJson["taxiCarTriggerPositionZ"] = taxiCarTriggerPosition.z();
// {"y", taxiCarTriggerPosition.y()},
// {"z", taxiCarTriggerPosition.z()}
//};
taxiJson["taxiArrivedCallbackCalled"] = taxiArrivedCallbackCalled;
//questJournal.save(questJson);
out["taxi"] = std::move(taxiJson);
}
void LocationState::load(const nlohmann::json& in)
@@ -113,122 +98,14 @@ void LocationState::load(const nlohmann::json& in)
activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex);
targetInteractiveObjectIndex = in.value("targetInteractiveObjectIndex", -1);
targetInteractNpcIndex = in.value("targetInteractNpcIndex", -1);
targetTeleportZoneIndex = in.value("targetTeleportZoneIndex", -1);
isDarklands = in.value("isDarklands", false);
isNight = in.value("isNight", false);
isDawn = in.value("isDawn", false);
//logger() << "LocationState::load step 2" << std::endl;
nlohmann::json taxiJson = in["taxi"];
taxiArrivedCallbackCalled = taxiJson.value("taxiArrivedCallbackCalled", false);
//----- Taxi State ----
nlohmann::json taxiCarJson = taxiJson["taxiCar"];
taxiCar.position = Eigen::Vector3f(
taxiCarJson.value("positionX", 0.f),
taxiCarJson.value("positionY", 0.f),
taxiCarJson.value("positionZ", 0.f)
);
//logger() << "LocationState::load step 3" << std::endl;
taxiCar.rotation = taxiCarJson.value("rotation", 0.f);
taxiCar.velocity = taxiCarJson.value("velocity", 0.f);
taxiCar.steeringAngle = taxiCarJson.value("steeringAngle", 0.f);
taxiCar.mode = static_cast<NpcCar::Mode>(taxiCarJson.value("mode", 0));
//logger() << "LocationState::load step 4" << std::endl;
taxiCar.waypoints.clear();
if (taxiCarJson.contains("waypoints") && taxiCarJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarJson["waypoints"].size()); ++i)
taxiCar.waypoints.push_back(Eigen::Vector3f(
taxiCarJson["waypoints"][i].value("x", 0.f),
taxiCarJson["waypoints"][i].value("y", 0.f),
taxiCarJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 5" << std::endl;
taxiCar.currentWaypoint = taxiCarJson.value("currentWaypoint", 0);
taxiCar.waypointReachRadius = taxiCarJson.value("waypointReachRadius", 3.f);
taxiCar.wheelAngle = taxiCarJson.value("wheelAngle", 0.f);
//--------- Taxi initial state -----
//logger() << "LocationState::load step 6" << std::endl;
nlohmann::json taxiCarInitialStateJson = taxiJson["taxiCarInitialState"];
taxiCarInitialState.position = Eigen::Vector3f(
taxiCarInitialStateJson.value("positionX", 0.f),
taxiCarInitialStateJson.value("positionY", 0.f),
taxiCarInitialStateJson.value("positionZ", 0.f)
);
taxiCarInitialState.rotation = taxiCarInitialStateJson.value("rotation", 0.f);
taxiCarInitialState.velocity = taxiCarInitialStateJson.value("velocity", 0.f);
taxiCarInitialState.steeringAngle = taxiCarInitialStateJson.value("steeringAngle", 0.f);
taxiCarInitialState.mode = static_cast<NpcCar::Mode>(taxiCarInitialStateJson.value("mode", 0));
//logger() << "LocationState::load step 7" << std::endl;
taxiCarInitialState.waypoints.clear();
if (taxiCarInitialStateJson.contains("waypoints") && taxiCarInitialStateJson["waypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiCarInitialStateJson["waypoints"].size()); ++i)
taxiCarInitialState.waypoints.push_back(Eigen::Vector3f(
taxiCarInitialStateJson["waypoints"][i].value("x", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("y", 0.f),
taxiCarInitialStateJson["waypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 8" << std::endl;
taxiCarInitialState.currentWaypoint = taxiCarInitialStateJson.value("currentWaypoint", 0);
taxiCarInitialState.waypointReachRadius = taxiCarInitialStateJson.value("waypointReachRadius", 3.f);
taxiCarInitialState.wheelAngle = taxiCarInitialStateJson.value("wheelAngle", 0.f);
taxiCarWaypoints.clear();
if (taxiJson.contains("taxiDefaultWaypoints") && taxiJson["taxiDefaultWaypoints"].is_array()) {
for (int i = 0; i < static_cast<int>(taxiJson["taxiDefaultWaypoints"].size()); ++i)
taxiCarWaypoints.push_back(Eigen::Vector3f(
taxiJson["taxiDefaultWaypoints"][i].value("x", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("y", 0.f),
taxiJson["taxiDefaultWaypoints"][i].value("z", 0.f)
));
}
//logger() << "LocationState::load step 9" << std::endl;
taxiCarTriggerPosition = Eigen::Vector3f(
taxiJson.value("taxiCarTriggerPositionX", 0.f),
taxiJson.value("taxiCarTriggerPositionY", 0.f),
taxiJson.value("taxiCarTriggerPositionZ", 0.f)
);
//logger() << "LocationState::load step 10" << std::endl;
targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string());
targetInteractNpcId = in.value("targetInteractNpcId", std::string());
targetTeleportZoneId = in.value("targetTeleportZoneId", std::string());
const auto& taxi = in.at("taxi");
loadCar(taxiCar, taxi.at("car"));
taxiArrivedCallbackCalled =
taxi.at("arrivedCallbackCalled").get<bool>();
}
} // namespace FRG
+12 -5
View File
@@ -2,10 +2,18 @@
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
#include "NpcCar.h"
#include "navigation/PathFinder.h"
#include <string>
namespace FRG {
struct LocationState : public ISaveable {
//This value is populated from the JSON configs and should not be changed during the regular gameplay.
//It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const;
void initNavigation(const std::vector<std::string>& paths);
// ---- Camera ----
float cameraAzimuth = -2.35f;
float cameraInclination = 1.1036f;
@@ -13,11 +21,10 @@ struct LocationState : public ISaveable {
// ---- Navigation ----
int activeNavigationIndex = 0;
// ---- Walk-to targets (-1 = none) ----
// Mirrors CharacterState's kNoTarget convention; raw pointers are not serialisable.
int targetInteractiveObjectIndex = -1;
int targetInteractNpcIndex = -1;
int targetTeleportZoneIndex = -1;
// ---- Walk-to targets (empty = none) ----
std::string targetInteractiveObjectId;
std::string targetInteractNpcId;
std::string targetTeleportZoneId;
// ---- World-mode flags (synced from Game after each transition) ----
bool isDarklands = false;
+131 -114
View File
@@ -201,9 +201,9 @@ namespace FRG {
}
#endif
MenuManager::MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer) :
MenuManager::MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer) :
renderer(iRenderer),
gameState_(gameState),
server_(server),
audioPlayer_(audioPlayer)
{
//Localization
@@ -338,9 +338,7 @@ namespace FRG {
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
}
void MenuManager::setup(Inventory& inv, const std::string& zipFile) {
inventory = &inv;
void MenuManager::setup(const std::string& zipFile) {
loadUiRoots(zipFile);
texObjectiveCompleted_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_completed.png", zipFile, true);
@@ -360,19 +358,6 @@ namespace FRG {
languageLoadingRu = renderer.textureManager.LoadFromPng("resources/loading_land003_ru.png", zipFile);
languageLoadingEn = renderer.textureManager.LoadFromPng("resources/loading_land003_en.png", zipFile);
gameState_.questJournal.onQuestUnlocked = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestNew, q->definition.title);
};
gameState_.questJournal.onQuestCompleted = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestCompleted, q->definition.title);
};
gameState_.questJournal.onQuestFailed = [this](const std::string& id) {
const Quest::QuestState* q = gameState_.questJournal.findQuest(id);
if (q) showToast(toastTextureQuestFailed, q->definition.title);
};
const std::string imgDir = "resources/w/ui/img/phone/";
texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true);
texBubbleInLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_top.png", zipFile, true);
@@ -419,9 +404,10 @@ namespace FRG {
}
std::shared_ptr<UiNode> MenuManager::gameplayHudRoot() const {
const std::string& loc = gameState_.currentLocationName;
if (loc == "uni_exterior") return gameState_.isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return gameState_.isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
const auto snapshot = server_.snapshot();
const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
return hudDormRoot; // dorm, or a new game before the location is set
}
@@ -434,21 +420,22 @@ namespace FRG {
}
void MenuManager::refreshHudVisibility() {
if (!inventory) return; // update() already ticks during loading, before setup()
const auto itemIds = server_.snapshot()->inventoryItemIds;
const TutorialStep step = gameState_.tutorialStep;
const bool tutorial = gameState_.isTutorialActive();
const bool hasPhone = inventory->hasItem("phone");
const bool hasJournal = inventory->hasItem("journal");
const bool hasNote = inventory->hasItem("note_spell");
const bool dark = gameState_.isDarklands;
const auto snapshot = server_.snapshot();
const TutorialStep step = snapshot->tutorialStep;
const bool tutorial = snapshot->isTutorialActive();
const bool hasPhone = std::find(itemIds.begin(), itemIds.end(), "phone") != itemIds.end();
const bool hasJournal = std::find(itemIds.begin(), itemIds.end(), "journal") != itemIds.end();
const bool hasNote = std::find(itemIds.begin(), itemIds.end(), "note_spell") != itemIds.end();
const bool dark = snapshot->isDarklands;
auto show = [this](std::initializer_list<const char*> names, bool visible) {
for (const char* name : names) uiManager.setNodeVisible(name, visible);
};
// --- HUD buttons: present while the matching item is in the inventory ---
show({ "inventoryButton" }, inventory->getCount() > 0);
show({ "inventoryButton" }, !itemIds.empty());
show({ "phoneButton" }, hasPhone && !dark);
show({ "journalButton" }, hasJournal);
show({ "darklandsButton" }, hasNote && !dark);
@@ -466,9 +453,9 @@ namespace FRG {
show({ "hint4" }, step == TutorialStep::Zoom);
show({ "hint5" }, step == TutorialStep::PickUpItem);
// ...then the hints for the picked-up items, which can be done in any order.
const bool chatHint = tutorial && !gameState_.tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !gameState_.tutorialJournalScreenOpened;
const bool taxiHint = tutorial && gameState_.tutorialNeedOpenTaxiScreen;
const bool chatHint = tutorial && !snapshot->tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !snapshot->tutorialJournalScreenOpened;
const bool taxiHint = tutorial && snapshot->tutorialNeedOpenTaxiScreen;
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
show({ "hint7", "hint7arrow" }, taxiHint);
@@ -485,7 +472,7 @@ namespace FRG {
}
void MenuManager::showMainMenu() {
gameState_.currentLocationName.clear();
server_.submit(SetCurrentLocationCommand{""});
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -546,7 +533,6 @@ namespace FRG {
}
void MenuManager::showMenuAfterGameCompleted() {
//gameState_.currentLocationName.clear();
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
@@ -1060,8 +1046,8 @@ namespace FRG {
steps.push_back([this]() {
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState_.campaignId))
gameState_.questJournal.reloadDefinitionsPreservingState(campaign->questsPath, CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(server_.snapshot()->campaignId))
server_.submit(ReloadQuestDefinitionsCommand{campaign->questsPath, CONST_ZIP_FILE});
});
steps.push_back([this]() {
@@ -1197,7 +1183,7 @@ namespace FRG {
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
const auto& items = inventory->getItems();
const auto items = inventoryItems();
/*logger() << "Item count: " << items.size() << std::endl;
for (int x = 0; x < items.size(); x++)
{
@@ -1236,7 +1222,7 @@ namespace FRG {
void MenuManager::selectInventoryItem(int index) {
logger() << "MenuManager::selectInventoryItem: " << index << std::endl;
const auto& items = inventory->getItems();
const auto items = inventoryItems();
if (index < 0 || index >= static_cast<int>(items.size())) return;
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
@@ -1288,7 +1274,10 @@ namespace FRG {
void MenuManager::openQuestJournal() {
uiState_ = GameUiState::QuestJournal;
gameState_.tutorialJournalScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
tutorial->tutorialPhoneChatScreenOpened, true,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
journalMenuStackDepth_ = 1;
const bool portrait = isPortraitMode();
@@ -1380,9 +1369,10 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
if (gameState_.isNight)
const auto timeState = server_.snapshot();
if (timeState->isNight)
{
if (gameState_.isDawn)
if (timeState->isDawn)
{
uiManager.setNodeVisible("phoneTimeDay", false);
uiManager.setNodeVisible("phoneTimeNight", false);
@@ -1418,51 +1408,67 @@ namespace FRG {
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
gameState_.chatUnread[0] = false;
setChatUnread(0, false);
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
gameState_.chatUnread[1] = false;
setChatUnread(1, false);
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
gameState_.chatUnread[2] = false;
setChatUnread(2, false);
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
}
void MenuManager::refreshChatUnreadIndicators() {
const auto state = server_.snapshot();
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
for (int i = 0; i < 3; ++i) {
uiManager.setNodeVisible(kUnreadNodes[i], gameState_.chatUnread[i]);
if (!gameState_.chatPreviewMsg[i].empty())
uiManager.setText(kMsgNodes[i], gameState_.chatPreviewMsg[i]);
uiManager.setNodeVisible(kUnreadNodes[i], state->chatUnread[i]);
if (!state->chatPreview[i].empty())
uiManager.setText(kMsgNodes[i], state->chatPreview[i]);
}
}
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
if (chatIndex < 0 || chatIndex > 2) return;
gameState_.chatUnread[chatIndex] = unread;
const auto state = server_.snapshot();
std::string preview = state->chatPreview[chatIndex];
if (!previewMsg.empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(previewMsg);
} else if (!unread && !gameState_.chatHistory[chatIndex].empty()) {
gameState_.chatPreviewMsg[chatIndex] = trimChatPreview(gameState_.chatHistory[chatIndex].back().text);
preview = trimChatPreview(previewMsg);
} else if (!unread && !state->chatHistory[chatIndex].empty()) {
preview = trimChatPreview(state->chatHistory[chatIndex].back().text);
}
server_.submit(SetChatCommand{chatIndex, unread, std::move(preview)});
}
void MenuManager::spendMoney(int amount) {
gameState_.money -= amount;
server_.submit(SpendMoneyCommand{amount});
}
std::vector<Item> MenuManager::inventoryItems() const {
std::vector<Item> items;
for (const std::string& id : server_.snapshot()->inventoryItemIds) {
if (const Item* item = ItemRegistry::instance().findById(id)) items.push_back(*item);
}
return items;
}
bool MenuManager::hasInventoryItem(const std::string& id) const {
const auto& ids = server_.snapshot()->inventoryItemIds;
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
void MenuManager::openPhoneBank() {
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneBankRoot : phoneBankRoot);
uiManager.setText("balanceText", formatMoney(gameState_.money));
uiManager.setText("balanceText", formatMoney(server_.snapshot()->money));
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
@@ -1492,13 +1498,15 @@ namespace FRG {
});
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
closePhoneEntirely();
if (gameState_.isNight)
const auto state = server_.snapshot();
if (state->isNight)
{
startDialogueFunc("dialog_video002");
}
else
{
auto day = gameState_.globalInts["day"];
auto dayIt = state->globalInts.find("day");
const int day = dayIt == state->globalInts.end() ? 0 : dayIt->second;
if (day == 0)
{
startDialogueFunc("dialog_video003");
@@ -1513,10 +1521,11 @@ namespace FRG {
}
void MenuManager::openPhoneTaxi() {
if (gameState_.currentLocationName == "uni_interior") {
const std::string location = server_.snapshot()->currentLocationId;
if (location == "uni_interior") {
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
} else if (gameState_.currentLocationName == "uni_exterior") {
} else if (location == "uni_exterior") {
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
} else {
@@ -1539,17 +1548,18 @@ namespace FRG {
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
gameState_.tutorialNeedOpenTaxiScreen = false;
gameState_.tutorialTaxiHintDone = true;
auto state = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
false, true});
if (gameState_.taxiIsCalled == false)
if (!state->taxiIsCalled)
{
gameState_.taxiIsCalled = true;
if (callTaxiFunc)
{
callTaxiFunc();
}
gameState_.money -= 500;
server_.submit(SetTaxiCalledCommand{true});
server_.submit(StartTaxiCommand{state->currentLocationId});
server_.submit(SetTriggerZoneEnabledCommand{
state->currentLocationId, "taxi_zone001", false});
server_.submit(SpendMoneyCommand{500});
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
@@ -1569,7 +1579,10 @@ namespace FRG {
activeChatIndex_ = chatIndex;
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
phoneChatVisibleBubbles_.clear();
gameState_.tutorialPhoneChatScreenOpened = true;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
true, tutorial->tutorialJournalScreenOpened,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
uiManager.pushMenuFromSavedRoot(chatRoot);
// Build a dedicated chatUiManager root sized to the FBO chat area
@@ -1616,11 +1629,7 @@ namespace FRG {
}
void MenuManager::returnToPhoneChatList() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
phoneChatVisibleBubbles_.clear();
@@ -1634,11 +1643,7 @@ namespace FRG {
void MenuManager::closePhoneEntirely() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2
&& !gameState_.chatHistory[activeChatIndex_].empty()) {
gameState_.chatPreviewMsg[activeChatIndex_] =
trimChatPreview(gameState_.chatHistory[activeChatIndex_].back().text);
}
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::None;
uiState_ = GameUiState::Gameplay;
@@ -1699,8 +1704,11 @@ namespace FRG {
void MenuManager::tutorialShowTaxiHint()
{
logger() << "tutorialShowTaxiHint" << std::endl;
if (!gameState_.isTutorialActive() || gameState_.tutorialTaxiHintDone) return;
gameState_.tutorialNeedOpenTaxiScreen = true; // cleared when the taxi is ordered
auto state = server_.snapshot();
if (!state->isTutorialActive() || state->tutorialTaxiHintDone) return;
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
true, state->tutorialTaxiHintDone});
refreshHudVisibility();
}
@@ -1723,16 +1731,17 @@ namespace FRG {
void MenuManager::onLocationChanged(const std::string& locationName) {
if (uiState_ != GameUiState::Gameplay) return;
gameState_.currentLocationName = locationName;
server_.submit(SetCurrentLocationCommand{locationName});
const auto state = server_.snapshot();
showGameplayHud();
if (locationName == "uni_exterior" || locationName == "uni_interior") {
if (gameState_.isDarklands)
if (state->isDarklands)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
lastMusicTrack = "music/bishkek fight calm.ogg";
}
else if (gameState_.isNight)
else if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1743,7 +1752,7 @@ namespace FRG {
lastMusicTrack = "music/bishkek univer day.ogg";
}
} else {
if (gameState_.isNight)
if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
@@ -1759,13 +1768,16 @@ namespace FRG {
void MenuManager::onItemPickedUp(const std::string& itemId) {
// The new item's HUD button appears through refreshHudVisibility().
if (itemId == "phone" || itemId == "journal")
gameState_.completeTutorialStep(TutorialStep::PickUpItem);
server_.submit(CompleteTutorialStepCommand{TutorialStep::PickUpItem});
refreshHudVisibility();
}
void MenuManager::refreshQuestJournalUi() {
visibleQuestIds.clear();
auto quests = gameState_.questJournal.getVisibleQuests();
const auto state = server_.snapshot();
std::vector<const Quest::QuestState*> quests;
quests.reserve(state->quests.size());
for (const auto& quest : state->quests) quests.push_back(&quest);
std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) {
const int pa = questStatusPriority(a->status);
@@ -1840,7 +1852,10 @@ namespace FRG {
}
selectedQuestIndex = index;
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) {
return;
}
@@ -1888,7 +1903,7 @@ namespace FRG {
}
void MenuManager::openPortraitInventoryItem(int index) {
const auto& items = inventory->getItems();
const auto items = inventoryItems();
inventorySelectedIndex_ = index;
inventoryMenuStackDepth_ = 2;
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
@@ -1943,7 +1958,10 @@ namespace FRG {
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
Quest::QuestState* quest = gameState_.questJournal.findQuest(visibleQuestIds[index]);
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
if (!quest) return;
const auto& def = quest->definition;
@@ -2070,7 +2088,8 @@ namespace FRG {
phoneChatVisibleBubbles_.clear();
if (chatIndex < 0 || chatIndex > 2) return;
for (const auto& msg : gameState_.chatHistory[chatIndex]) {
const auto state = server_.snapshot();
for (const auto& msg : state->chatHistory[chatIndex]) {
const bool inc = msg.incoming;
const std::string nodeName = chatUiManager.addChatBubble(
"chatMessagesContainer", msg.text, inc,
@@ -2100,11 +2119,7 @@ namespace FRG {
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
if (activeChatIndex_ < 0) return;
auto& history = gameState_.chatHistory[activeChatIndex_];
if (static_cast<int>(history.size()) >= 5) {
history.erase(history.begin());
}
history.push_back({ text, incoming });
server_.submit(AppendChatMessageCommand{activeChatIndex_, text, incoming});
if (uiState_ != GameUiState::PhoneScreen) return;
@@ -2143,8 +2158,8 @@ namespace FRG {
{
logger() << "MenuManager::setDarklandsMode called" << std::endl;
if (enabled && gameState_.currentLocationName == "uni_interior" && inventory->hasItem("note_spell"))
gameState_.completeTutorialStep(TutorialStep::EnterDarklands);
if (enabled && server_.snapshot()->currentLocationId == "uni_interior" && hasInventoryItem("note_spell"))
server_.submit(CompleteTutorialStepCommand{TutorialStep::EnterDarklands});
if (uiState_ == GameUiState::Gameplay)
showGameplayHud(); // darklands has its own HUD layouts
@@ -2152,30 +2167,31 @@ namespace FRG {
void MenuManager::onPlayerStartedWalking()
{
if (gameState_.currentLocationName == "uni_interior" && gameState_.isDarklands)
gameState_.completeTutorialStep(TutorialStep::DarklandsHealth);
const auto state = server_.snapshot();
if (state->currentLocationId == "uni_interior" && state->isDarklands)
server_.submit(CompleteTutorialStepCommand{TutorialStep::DarklandsHealth});
}
void MenuManager::advanceUniIntDarklandsHud()
{
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
if (gameState_.tutorialStep == TutorialStep::DarklandsHealth
|| gameState_.tutorialStep == TutorialStep::DarklandsExplore)
gameState_.tutorialStep = TutorialStep::DarklandsAttack;
const TutorialStep step = server_.snapshot()->tutorialStep;
if (step == TutorialStep::DarklandsHealth || step == TutorialStep::DarklandsExplore)
server_.submit(SetTutorialStepCommand{TutorialStep::DarklandsAttack});
}
void MenuManager::onEnemyKilledInUniInterior()
{
// Every location's enemies report here; only the university interior drives the tutorial.
if (gameState_.currentLocationName != "uni_interior") return;
const auto state = server_.snapshot();
if (state->currentLocationId != "uni_interior") return;
// The last tutorial step; like the attack hint, also ends the earlier darklands hints.
if (gameState_.tutorialStep >= TutorialStep::DarklandsHealth)
gameState_.tutorialStep = TutorialStep::Completed;
if (state->tutorialStep >= TutorialStep::DarklandsHealth)
server_.submit(SetTutorialStepCommand{TutorialStep::Completed});
}
void MenuManager::updateHealthBar(float hp, float maxHp) {
gameState_.playerHp = hp;
gameState_.playerMaxHp = maxHp;
server_.submit(SetPlayerHealthCommand{hp, maxHp});
applyCurrentHealthBar();
}
@@ -2191,21 +2207,22 @@ namespace FRG {
cutsceneHudActive_ = false;
topUiManager.replaceRoot(nullptr);
if (uiState_ == GameUiState::Gameplay)
onLocationChanged(gameState_.currentLocationName);
onLocationChanged(server_.snapshot()->currentLocationId);
}
void MenuManager::applyCurrentHealthBar() {
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
logger() << "currentPlayerMaxHp_ is " << gameState_.playerMaxHp << std::endl;
if (gameState_.playerMaxHp <= 0.f) return;
const auto state = server_.snapshot();
logger() << "currentPlayerMaxHp_ is " << state->playerMaxHp << std::endl;
if (state->playerMaxHp <= 0.f) return;
logger() << "MenuManager::applyCurrentHealthBar called step 2" << std::endl;
logger() << "currentPlayerHp_ is " << gameState_.playerHp << std::endl;
logger() << "currentPlayerHp_ is " << state->playerHp << std::endl;
const float fraction = std::clamp(gameState_.playerHp / gameState_.playerMaxHp, 0.f, 1.f);
const float fraction = std::clamp(state->playerHp / state->playerMaxHp, 0.f, 1.f);
uiManager.setSliderValue("healthBarFill", fraction);
std::string hpText = std::to_string(static_cast<int>(gameState_.playerHp)) + "/" +
std::to_string(static_cast<int>(gameState_.playerMaxHp));
std::string hpText = std::to_string(static_cast<int>(state->playerHp)) + "/" +
std::to_string(static_cast<int>(state->playerMaxHp));
if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' ');
uiManager.setText("healthValue", hpText);
}
@@ -2447,7 +2464,7 @@ namespace FRG {
openInventory();
const int itemCount = static_cast<int>(inventory->getItems().size());
const int itemCount = static_cast<int>(inventoryItems().size());
if (isPortraitMode() && wasOnItemDetail &&
savedIndex >= 0 && savedIndex < itemCount) {
selectInventoryItem(savedIndex);
+10 -9
View File
@@ -3,8 +3,9 @@
#include "Environment.h"
#include "render/TextureManager.h"
#include "UiManager.h"
#include "Localization.h"
#include "items/Item.h"
#include "GameState.h"
#include "game_server/IGameServer.h"
#include <vector>
#include <deque>
#include <string>
@@ -39,9 +40,9 @@ namespace FRG {
std::unordered_map<std::string,
std::unordered_map<Language, std::string>> localizedLocationName;
MenuManager(Renderer& iRenderer, GameState& gameState, AudioPlayerAsync& audioPlayer);
MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer);
void setup(Inventory& inv, const std::string& zipFile);
void setup(const std::string& zipFile);
void openInventory();
void selectInventoryItem(int index);
@@ -80,7 +81,7 @@ namespace FRG {
void setChatUnread(int chatIndex, bool unread, const std::string& previewMsg = "");
void spendMoney(int amount);
int getMoney() const { return gameState_.money; }
int getMoney() const { return server_.snapshot()->money; }
// Starts a new game of the given campaign (see CampaignRegistry).
std::function<void(const std::string& campaignId)> startGameFunc;
@@ -89,7 +90,6 @@ namespace FRG {
std::function<void()> startNightTransitionFunc;
std::function<void(int)> chatOpenCallback;
std::function<void()> skipCutsceneFunc;
std::function<void()> callTaxiFunc;
std::function<void(int)> onSaveGame;
std::function<void(int)> onLoadGame;
std::function<SaveSlotInfo(int)> getSlotInfoFunc;
@@ -172,14 +172,14 @@ namespace FRG {
Renderer& renderer;
private:
GameState& gameState_;
IGameServer& server_;
AudioPlayerAsync& audioPlayer_;
void startNewGame(const std::string& campaignId);
// HUD layout for the current location and darklands mode.
std::shared_ptr<UiNode> gameplayHudRoot() const;
// Single place that decides which HUD buttons and tutorial hints are visible:
// buttons follow the inventory, hints follow gameState_.tutorialStep and the
// buttons follow the inventory snapshot, hints follow the server tutorial state and
// tutorial flags. Runs every frame; names absent from the shown screen are skipped.
void refreshHudVisibility();
void refreshQuestJournalUi();
@@ -249,7 +249,8 @@ namespace FRG {
static constexpr float TOAST_VISIBLE_MS = 2000.0f;
GameUiState uiState_ = GameUiState::Gameplay;
Inventory* inventory = nullptr;
std::vector<Item> inventoryItems() const;
bool hasInventoryItem(const std::string& id) const;
bool isPlayerInCreditsMenu = false;
int inventorySelectedIndex_ = -1;
@@ -336,7 +337,7 @@ namespace FRG {
enum class PhoneSubScreen { None, Main, Bank, Video, MapDorm, MapUni, ChatList, Chat };
PhoneSubScreen currentPhoneSubScreen_ = PhoneSubScreen::None;
// Phone chat UI state (transient — rebuilt from gameState_.chatHistory on open)
// Phone chat UI state (transient — rebuilt from the immutable server snapshot)
struct PhoneChatBubbleInfo {
std::string nodeName;
float height;
+294 -270
View File
@@ -1,5 +1,6 @@
#include "ScriptEngine.h"
#include "ScriptEngine.h"
#include "Game.h"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <unordered_map>
@@ -14,26 +15,11 @@ namespace FRG {
namespace {
// Wraps a sol::protected_function into a std::function<void()>, invoking it and
// logging any Lua-side error to stderr. Shared by every binding that stores a
// callback for later, one-shot invocation (npc_walk_to, move_object, etc.) and by
// rehydrateNamedCallback, which re-wraps a callback looked up by name after load.
std::function<void()> wrapLuaCallback(sol::protected_function fn) {
return [fn]() mutable {
auto result = fn();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[script] callback error: " << err.what() << "\n";
}
};
}
// Reverse-looks-up the global name a Lua function is registered under, by
// comparing raw function pointers via lua_topointer. Returns "" if fn isn't
// (or is no longer) reachable as a plain global — e.g. an anonymous function,
// or one stored only in a local/upvalue. This lets callback-accepting bindings
// remember a callback "by name" for save/load without changing the Lua-facing
// API (scripts still just pass a function reference).
// remember a callback by name while scripts continue to pass function references.
std::string findGlobalFunctionName(lua_State* L, const sol::protected_function& fn) {
if (!fn.valid()) return {};
@@ -58,6 +44,19 @@ namespace FRG {
return found;
}
Character* findNpc(Location* location, const std::string& id) {
for (auto& npc : location->npcs)
if (npc && npc->entityId() == id) return npc.get();
return nullptr;
}
InteractiveObject* findInteractiveObject(
Location* location, const std::string& id) {
for (auto& object : location->interactiveObjects)
if (object.entityId() == id) return &object;
return nullptr;
}
} // anonymous namespace
struct ScriptEngine::Impl {
@@ -65,11 +64,12 @@ namespace FRG {
std::unordered_map<std::string, sol::protected_function> triggerEnterCallbacks;
std::unordered_map<std::string, sol::protected_function> triggerExitCallbacks;
std::unordered_map<std::string, sol::protected_function> cutsceneCompleteCallbacks;
std::unordered_map<int, sol::protected_function> npcBumpedByPlayerCallbacks;
std::unordered_map<int, sol::protected_function> npcBumpsPlayerCallbacks;
std::unordered_map<std::string, sol::protected_function> npcBumpedByPlayerCallbacks;
std::unordered_map<std::string, sol::protected_function> npcBumpsPlayerCallbacks;
std::unordered_map<std::string, int>* globalInts = nullptr;
std::unordered_map<std::string, float>* globalFloats = nullptr;
Quest::QuestJournal* questJournal = nullptr;
sol::protected_function locationInitCallback;
sol::protected_function locationEnterCallback;
sol::protected_function locationExitCallback;
sol::protected_function darklandsEnterCallback;
@@ -110,82 +110,92 @@ namespace FRG {
auto api = lua.create_named_table("game_api");
// npc_walk_to(index, x, y, z [, on_arrived])
// npc_walk_to(npc_id, x, y, z [, on_arrived])
// on_arrived is an optional Lua function called when the NPC reaches the target.
// It can call npc_walk_to again (or anything else) to chain behaviour.
api.set_function("npc_walk_to",
[loc, this_impl = impl.get()](int index, float x, float y, float z, sol::object on_arrived) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_walk_to: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc, this_impl = impl.get()](const std::string& npcId, float x, float y, float z, sol::object on_arrived) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_walk_to: unknown NPC id '" << npcId << "'\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (on_arrived.is<sol::protected_function>()) {
sol::protected_function fn = on_arrived.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] npc_walk_to: on_arrived callback has no global name, will not survive save/load\n";
std::cerr << "[script] npc_walk_to: on_arrived must be a global Lua function\n";
}
npcs[index]->state.homePosition = Eigen::Vector3f(x, 0.f, z);
npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb), std::move(cbName));
loc->updateCharacterState(*npc, [x, z](CharacterState& workingState) {
workingState.homePosition = Eigen::Vector3f(x, 0.f, z);
});
loc->setCharacterTarget(
*npc, Eigen::Vector3f(x, y, z), std::move(cbName));
});
api.set_function("npc_rotate_to",
[loc](int index, float angle) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_rotate_to: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId, float angle) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_rotate_to: unknown NPC id '" << npcId << "'\n";
return;
}
const float rad = angle * static_cast<float>(M_PI) / 180.f;
logger() << "[script] npc_rotate_to: index " << index << " angle " << angle << " degrees (" << rad << " radians)" << std::endl;
npcs[index]->state.targetFacingAngle = rad;
logger() << "[script] npc_rotate_to: " << npcId << " angle " << angle << " degrees (" << rad << " radians)" << std::endl;
loc->updateCharacterState(*npc, [rad](CharacterState& workingState) {
workingState.targetFacingAngle = rad;
});
});
api.set_function("player_rotate_to",
[loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f;
logger() << "[script] player_rotate_to: angle " << angle << " degrees (" << rad << " radians)" << std::endl;
loc->player->state.targetFacingAngle = rad;
loc->updateCharacterState(*loc->player, [rad](CharacterState& workingState) {
workingState.targetFacingAngle = rad;
});
});
api.set_function("player_stop_and_rotate_to_npc",
[loc](int index) {
loc->player->stopAndRotateToTarget(index);
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] player_stop_and_rotate_to_npc: unknown NPC id '"
<< npcId << "'\n";
return;
}
loc->faceCharacter(*loc->player, npc->entityId());
});
api.set_function("npc_stop_and_rotate_to_player",
[loc](int index) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_stop_and_rotate_to_player: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_stop_and_rotate_to_player: unknown NPC id '"
<< npcId << "'\n";
return;
}
npcs[index]->stopAndRotateToTarget(CharacterState::kPlayerIndex);
loc->faceCharacter(*npc, "player");
});
api.set_function("player_walk_to",
[loc, this_impl = impl.get()](float x, float y, float z, sol::object on_arrived) {
std::function<void()> cb;
std::string cbName;
if (on_arrived.is<sol::protected_function>()) {
sol::protected_function fn = on_arrived.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] player_walk_to: on_arrived callback has no global name, will not survive save/load\n";
std::cerr << "[script] player_walk_to: on_arrived must be a global Lua function\n";
}
loc->player->state.homePosition = Eigen::Vector3f(x, 0.f, z);
loc->player->setTarget(Eigen::Vector3f(x, y, z), std::move(cb), std::move(cbName));
loc->updateCharacterState(*loc->player, [x, z](CharacterState& workingState) {
workingState.homePosition = Eigen::Vector3f(x, 0.f, z);
});
loc->setCharacterTarget(
*loc->player, Eigen::Vector3f(x, y, z), std::move(cbName));
});
@@ -225,50 +235,44 @@ namespace FRG {
inventory->removeItem(id);
});
// deactivate_interactive_object(object_name)
api.set_function("deactivate_interactive_object", [loc](const std::string& objectName) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.isActive = false;
logger() << "[script] deactivate_interactive_object: " << objectName << std::endl;
return;
}
api.set_function("deactivate_interactive_object", [loc](const std::string& objectId) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.isActive = false;
logger() << "[script] deactivate_interactive_object: " << objectId << std::endl;
return;
}
std::cerr << "[script] deactivate_interactive_object: not found: " << objectName << std::endl;
std::cerr << "[script] deactivate_interactive_object: unknown object id: " << objectId << std::endl;
});
// activate_interactive_object(object_name)
api.set_function("activate_interactive_object", [loc](const std::string& objectName) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.isActive = true;
logger() << "[script] activate_interactive_object: " << objectName << std::endl;
return;
}
api.set_function("activate_interactive_object", [loc](const std::string& objectId) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.isActive = true;
logger() << "[script] activate_interactive_object: " << objectId << std::endl;
return;
}
std::cerr << "[script] activate_interactive_object: not found: " << objectName << std::endl;
std::cerr << "[script] activate_interactive_object: unknown object id: " << objectId << std::endl;
});
api.set_function("set_object_rotation", [loc](const std::string& objectName, float value) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.rotationY = value * static_cast<float>(M_PI) / 180.f;
logger() << "[script] set_object_rotation: " << objectName << " " << value << std::endl;
return;
}
api.set_function("set_object_rotation", [loc](const std::string& objectId, float value) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.rotationY = value * static_cast<float>(M_PI) / 180.f;
logger() << "[script] set_object_rotation: " << objectId << " " << value << std::endl;
return;
}
std::cerr << "[script] set_object_rotation: not found: " << objectName << std::endl;
std::cerr << "[script] set_object_rotation: unknown object id: " << objectId << std::endl;
});
api.set_function("set_object_alpha", [loc](const std::string& objectName, float value) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name == objectName) {
intObj.state.alpha = value;
logger() << "[script] set_object_alpha: " <<objectName << " " << value << std::endl;
return;
}
api.set_function("set_object_alpha", [loc](const std::string& objectId, float value) {
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (object) {
object->state.alpha = value;
logger() << "[script] set_object_alpha: " << objectId << " " << value << std::endl;
return;
}
std::cerr << "[script] set_object_alpha: not found: " << objectName << std::endl;
std::cerr << "[script] set_object_alpha: unknown object id: " << objectId << std::endl;
});
// get_inventory_count()
@@ -332,7 +336,15 @@ namespace FRG {
// on_enter and on_exit are optional Lua functions (pass nil to omit).
// Called when the player enters or exits the named trigger zone.
api.set_function("set_trigger_zone_callbacks",
[this_impl = impl.get()](const std::string& zoneId, sol::object onEnter, sol::object onExit) {
[loc, this_impl = impl.get()](const std::string& zoneId, sol::object onEnter, sol::object onExit) {
const bool exists = std::any_of(
loc->triggerZones.begin(), loc->triggerZones.end(),
[&zoneId](const TriggerZone& zone) { return zone.id == zoneId; });
if (!exists) {
std::cerr << "[script] set_trigger_zone_callbacks: unknown zone id '"
<< zoneId << "'\n";
return;
}
if (onEnter.is<sol::protected_function>())
this_impl->triggerEnterCallbacks[zoneId] = onEnter.as<sol::protected_function>();
if (onExit.is<sol::protected_function>())
@@ -386,11 +398,15 @@ namespace FRG {
loc->requestAdvanceDarklandsHud();
});
// set_location_callbacks(on_enter, on_exit)
// set_location_callbacks(on_init, on_enter, on_exit)
// on_init() is called only when this location has no retained state.
// on_enter() called once when the player arrives at this location.
// on_exit() called once just before the player leaves this location.
api.set_function("set_location_callbacks",
[this_impl = impl.get()](sol::object onEnter, sol::object onExit) {
[this_impl = impl.get()](
sol::object onInit, sol::object onEnter, sol::object onExit) {
if (onInit.is<sol::protected_function>())
this_impl->locationInitCallback = onInit.as<sol::protected_function>();
if (onEnter.is<sol::protected_function>())
this_impl->locationEnterCallback = onEnter.as<sol::protected_function>();
if (onExit.is<sol::protected_function>())
@@ -415,11 +431,11 @@ namespace FRG {
api.set_function("get_player_x",
[loc]() -> float {
return loc->player ? loc->player->state.position.x() : 0.0f;
return loc->player ? loc->player->characterState.position.x() : 0.0f;
});
api.set_function("get_player_z",
[loc]() -> float {
return loc->player ? loc->player->state.position.z() : 0.0f;
return loc->player ? loc->player->characterState.position.z() : 0.0f;
});
// set_player_hp(value) — sets the player's current HP directly.
@@ -427,12 +443,14 @@ namespace FRG {
[loc](float value) {
if (loc->player)
{
loc->player->state.setHp(value);
if (loc->player->state.currentState == AnimationState::ACTION_TO_DEATH || loc->player->state.currentState == AnimationState::DEATH_IDLE)
{
loc->player->state.currentState = AnimationState::STAND;
loc->player->state.resetAnim = true;
}
loc->setCharacterHp(*loc->player, value);
loc->updateCharacterState(*loc->player, [](CharacterState& workingState) {
if (workingState.currentState == AnimationState::ACTION_TO_DEATH ||
workingState.currentState == AnimationState::DEATH_IDLE) {
workingState.currentState = AnimationState::STAND;
workingState.resetAnim = true;
}
});
}
});
// get_player_hp() → float (0 if no player)
@@ -537,246 +555,253 @@ namespace FRG {
});
api.set_function("set_trigger_zone_enabled",
[loc](int index, bool value) {
auto& triggerZones = loc->triggerZones;
if (index < 0 || index >= static_cast<int>(triggerZones.size())) {
std::cerr << "[script] set_trigger_zone_enabled: index " << index << " out of range\n";
[loc](const std::string& zoneId, bool value) {
for (auto& zone : loc->triggerZones) {
if (zone.id != zoneId) continue;
zone.enabled = value;
return;
}
triggerZones[index].enabled = value;
std::cerr << "[script] set_trigger_zone_enabled: unknown zone id '"
<< zoneId << "'\n";
});
// set_teleport_active(index, active) — activates or deactivates a teleport zone by index.
// set_teleport_active(zone_id, active)
// Swaps the particle effect texture to match the new state.
api.set_function("set_teleport_active",
[loc](int index, bool value) {
auto& zones = loc->teleportZones;
if (index < 0 || index >= static_cast<int>(zones.size())) {
std::cerr << "[script] set_teleport_active: index " << index << " out of range\n";
[loc](const std::string& zoneId, bool value) {
for (auto& zone : loc->teleportZones) {
if (zone.id != zoneId) continue;
zone.setActive(value);
return;
}
zones[index].setActive(value);
std::cerr << "[script] set_teleport_active: unknown zone id '"
<< zoneId << "'\n";
});
// is_teleport_active(index) → bool
// is_teleport_active(zone_id) → bool
api.set_function("is_teleport_active",
[loc](int index) -> bool {
auto& zones = loc->teleportZones;
if (index < 0 || index >= static_cast<int>(zones.size())) {
std::cerr << "[script] is_teleport_active: index " << index << " out of range\n";
return false;
}
return zones[index].active;
[loc](const std::string& zoneId) -> bool {
for (const auto& zone : loc->teleportZones)
if (zone.id == zoneId) return zone.active;
std::cerr << "[script] is_teleport_active: unknown zone id '"
<< zoneId << "'\n";
return false;
});
// npc_set_hp(index, value) — sets an NPC's current HP directly.
// npc_set_hp(npc_id, value)
api.set_function("npc_set_hp",
[loc](int index, float value) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_hp: index " << index << " out of range\n";
[loc](const std::string& npcId, float value) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_hp: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->setHp(value);
loc->setCharacterHp(*npc, value);
});
// npc_set_position(index, x, y, z) — teleports an NPC instantly, no walking.
// npc_set_position(npc_id, x, y, z)
api.set_function("npc_set_position",
[loc](int index, float x, float y, float z) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_position: index " << index << " out of range\n";
[loc](const std::string& npcId, float x, float y, float z) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_position: unknown NPC id '" << npcId << "'\n";
return;
}
Eigen::Vector3f pos(x, y, z);
npcs[index]->state.position = pos;
npcs[index]->state.homePosition = pos;
npcs[index]->setTarget(pos);
loc->updateCharacterState(*npc, [&pos](CharacterState& workingState) {
workingState.position = pos;
workingState.homePosition = pos;
});
loc->setCharacterTarget(*npc, pos);
});
// npc_set_rotation(index, angle) — sets NPC facing angle around Y axis (degrees).
// npc_set_rotation(npc_id, angle)
api.set_function("npc_set_rotation",
[loc](int index, float angle) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_set_rotation: index " << index << " out of range\n";
[loc](const std::string& npcId, float angle) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_set_rotation: unknown NPC id '" << npcId << "'\n";
return;
}
const float rad = angle * static_cast<float>(M_PI) / 180.f;
npcs[index]->state.facingAngle = rad;
npcs[index]->state.targetFacingAngle = rad;
loc->updateCharacterState(*npc, [rad](CharacterState& workingState) {
workingState.facingAngle = rad;
workingState.targetFacingAngle = rad;
});
});
// set_npc_enabled(index, enabled)
// set_npc_enabled(npc_id, enabled)
api.set_function("set_npc_enabled",
[loc](int index, bool value) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] set_npc_enabled: index " << index << " out of range\n";
[loc](const std::string& npcId, bool value) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] set_npc_enabled: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->state.enabled = value;
npcs[index]->state.attack = 0;
npcs[index]->state.attack_cooldown = 0;
npcs[index]->state.currentState = AnimationState::STAND;
if (npcs[index]->state.canAttack)
{
npcs[index]->state.attackTargetIndex = CharacterState::kPlayerIndex;
}
npcs[index]->state.battle_state = 0;
loc->updateCharacterState(*npc, [value](CharacterState& workingState) {
workingState.enabled = value;
workingState.attack = 0;
workingState.attack_cooldown = 0;
workingState.currentState = AnimationState::STAND;
if (workingState.canAttack)
workingState.attackTargetId = "player";
workingState.battle_state = 0;
});
});
api.set_function("player_set_position",
[loc](float x, float y, float z) {
Eigen::Vector3f pos(x, y, z);
loc->player->state.position = pos;
loc->player->state.homePosition = pos;
loc->player->setTarget(pos);
loc->updateCharacterState(*loc->player, [&pos](CharacterState& workingState) {
workingState.position = pos;
workingState.homePosition = pos;
});
loc->setCharacterTarget(*loc->player, pos);
});
api.set_function("player_set_rotation",
[loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f;
loc->player->state.facingAngle = rad;
loc->player->state.targetFacingAngle = rad;
loc->updateCharacterState(*loc->player, [rad](CharacterState& workingState) {
workingState.facingAngle = rad;
workingState.targetFacingAngle = rad;
});
});
// move_object(name, x, y, z, duration_sec [, on_complete])
api.set_function("move_object",
[loc, this_impl = impl.get()](const std::string& name, float x, float y, float z,
[loc, this_impl = impl.get()](const std::string& objectId, float x, float y, float z,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] move_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] move_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.moveTo(Eigen::Vector3f(x, y, z), durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] move_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] move_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] move_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] move_object: on_complete must be a global Lua function\n";
}
object->state.moveTo(
Eigen::Vector3f(x, y, z), durationSec, std::move(cbName));
});
// rotate_object(name, angle_deg, duration_sec [, on_complete])
api.set_function("rotate_object",
[loc, this_impl = impl.get()](const std::string& name, float angleDeg,
[loc, this_impl = impl.get()](const std::string& objectId, float angleDeg,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] rotate_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] rotate_object: on_complete callback has no global name, will not survive save/load\n";
}
const float angleRad = angleDeg * static_cast<float>(M_PI) / 180.f;
intObj.rotateTo(intObj.state.rotationY + angleRad, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] rotate_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] rotate_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] rotate_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] rotate_object: on_complete must be a global Lua function\n";
}
const float angleRad = angleDeg * static_cast<float>(M_PI) / 180.f;
object->state.rotateTo(
object->state.rotationY + angleRad, durationSec, std::move(cbName));
});
// fade_object(name, target_alpha, duration_sec [, on_complete])
api.set_function("fade_object",
[loc, this_impl = impl.get()](const std::string& name, float targetAlpha,
[loc, this_impl = impl.get()](const std::string& objectId, float targetAlpha,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] fade_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] fade_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.fadeTo(targetAlpha, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] fade_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] fade_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] fade_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] fade_object: on_complete must be a global Lua function\n";
}
object->state.fadeTo(targetAlpha, durationSec, std::move(cbName));
});
// scale_object(name, target_scale, duration_sec [, on_complete])
api.set_function("scale_object",
[loc, this_impl = impl.get()](const std::string& name, float targetScale,
[loc, this_impl = impl.get()](const std::string& objectId, float targetScale,
float durationSec, sol::object onComplete) {
for (auto& intObj : loc->interactiveObjects) {
if (intObj.loadedObject.name != name) continue;
if (intObj.state.isAnimating) {
std::cerr << "[script] scale_object: '" << name << "' is already animating\n";
return;
}
std::function<void()> cb;
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cb = wrapLuaCallback(fn);
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] scale_object: on_complete callback has no global name, will not survive save/load\n";
}
intObj.scaleTo(targetScale, durationSec, std::move(cb), std::move(cbName));
InteractiveObject* object = findInteractiveObject(loc, objectId);
if (!object) {
std::cerr << "[script] scale_object: unknown object id '" << objectId << "'\n";
return;
}
std::cerr << "[script] scale_object: object '" << name << "' not found\n";
if (object->state.isAnimating) {
std::cerr << "[script] scale_object: '" << objectId << "' is already animating\n";
return;
}
std::string cbName;
if (onComplete.is<sol::protected_function>()) {
sol::protected_function fn = onComplete.as<sol::protected_function>();
cbName = findGlobalFunctionName(this_impl->lua.lua_state(), fn);
if (cbName.empty())
std::cerr << "[script] scale_object: on_complete must be a global Lua function\n";
}
object->state.scaleTo(targetScale, durationSec, std::move(cbName));
});
api.set_function("resetPlayerAfterDeath",
[loc]() {
if (loc->player) loc->player->resetPlayerAfterDeath();
if (loc->player) loc->resetCharacterAfterDeath(*loc->player);
});
// player_stop() — cancels the player's current walk target and stops them immediately.
api.set_function("player_stop",
[loc]() {
if (loc->player) loc->player->stopInPlace();
if (loc->player) loc->stopCharacter(*loc->player);
});
// npc_stop(index) — cancels an NPC's current walk target and stops them immediately.
// npc_stop(npc_id)
api.set_function("npc_stop",
[loc](int index) {
auto& npcs = loc->npcs;
if (index < 0 || index >= static_cast<int>(npcs.size())) {
std::cerr << "[script] npc_stop: index " << index
<< " out of range (0.." << npcs.size() - 1 << ")\n";
[loc](const std::string& npcId) {
Character* npc = findNpc(loc, npcId);
if (!npc) {
std::cerr << "[script] npc_stop: unknown NPC id '" << npcId << "'\n";
return;
}
npcs[index]->stopInPlace();
loc->stopCharacter(*npc);
});
// set_npc_bump_callbacks(npc_index, on_player_bumps_npc, on_npc_bumps_player)
// set_npc_bump_callbacks(npc_id, on_player_bumps_npc, on_npc_bumps_player)
// Both callbacks are optional (pass nil to omit).
// on_player_bumps_npc() — player was moving and walked into the NPC.
// on_npc_bumps_player() — the NPC was moving and walked into the standing player.
// Each callback fires at most once every 5 seconds per NPC (cooldown managed by Location).
api.set_function("set_npc_bump_callbacks",
[this_impl = impl.get()](int index, sol::object onPlayerBumpsNpc, sol::object onNpcBumpsPlayer) {
[loc, this_impl = impl.get()](const std::string& npcId, sol::object onPlayerBumpsNpc, sol::object onNpcBumpsPlayer) {
if (!findNpc(loc, npcId)) {
std::cerr << "[script] set_npc_bump_callbacks: unknown NPC id '"
<< npcId << "'\n";
return;
}
if (onPlayerBumpsNpc.is<sol::protected_function>())
this_impl->npcBumpedByPlayerCallbacks[index] = onPlayerBumpsNpc.as<sol::protected_function>();
this_impl->npcBumpedByPlayerCallbacks[npcId] = onPlayerBumpsNpc.as<sol::protected_function>();
if (onNpcBumpsPlayer.is<sol::protected_function>())
this_impl->npcBumpsPlayerCallbacks[index] = onNpcBumpsPlayer.as<sol::protected_function>();
this_impl->npcBumpsPlayerCallbacks[npcId] = onNpcBumpsPlayer.as<sol::protected_function>();
});
// quest_unlock(quest_id)
@@ -896,7 +921,7 @@ namespace FRG {
#endif
}
void ScriptEngine::callNpcInteractCallback(int npcIndex) {
void ScriptEngine::callNpcInteractCallback(const std::string& npcId) {
if (!impl) {
std::cerr << "[SCRIPT] Engine not initialized!" << std::endl;
return;
@@ -904,13 +929,13 @@ namespace FRG {
sol::state& lua = impl->lua;
sol::function fn = lua["on_npc_interact"];
if (fn.valid()) {
auto result = fn(npcIndex);
auto result = fn(npcId);
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_npc_interact error: " << err.what() << "\n";
}
else {
logger() << "[SCRIPT] on_npc_interact called with index " << npcIndex << std::endl;
logger() << "[SCRIPT] on_npc_interact called with id " << npcId << std::endl;
}
}
else {
@@ -926,26 +951,26 @@ namespace FRG {
}
}*/
void ScriptEngine::callActivateFunction(const std::string& functionName) {
void ScriptEngine::callFunction(const std::string& functionName) {
if (!impl) {
throw std::runtime_error("[SCRIPT] Engine not initialized!");
}
if (functionName.empty()) {
throw std::runtime_error("[SCRIPT] Activate function name is empty!");
throw std::runtime_error("[SCRIPT] Function name is empty!");
}
sol::state& lua = impl->lua;
logger() << "[SCRIPT] Looking for activate function: " << functionName << std::endl;
logger() << "[SCRIPT] Looking for function: " << functionName << std::endl;
sol::function activateFunc = lua[functionName];
sol::protected_function function = lua[functionName];
if (!activateFunc.valid()) {
if (!function.valid()) {
throw std::runtime_error("[SCRIPT] Lua function not found: " + functionName);
}
logger() << "[SCRIPT] Found function! Calling: " << functionName << std::endl;
auto result = activateFunc();
auto result = function();
if (!result.valid()) {
sol::error err = result;
@@ -955,6 +980,10 @@ namespace FRG {
logger() << "[SCRIPT] Function executed successfully!" << std::endl;
}
void ScriptEngine::callActivateFunction(const std::string& functionName) {
callFunction(functionName);
}
void ScriptEngine::callItemPickupCallback(const std::string& objectName) {
if (!impl) {
std::cerr << "[SCRIPT] impl is null!" << std::endl;
@@ -1016,6 +1045,15 @@ namespace FRG {
if (impl) impl->questJournal = journal;
}
void ScriptEngine::callLocationInitCallback() {
if (!impl || !impl->locationInitCallback.valid()) return;
auto result = impl->locationInitCallback();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] location init callback error: " << err.what() << "\n";
}
}
void ScriptEngine::callLocationEnterCallback() {
if (!impl || !impl->locationEnterCallback.valid()) return;
auto result = impl->locationEnterCallback();
@@ -1082,14 +1120,14 @@ namespace FRG {
}
}
void ScriptEngine::callNpcBumpedByPlayerCallback(int npcIndex) {
void ScriptEngine::callNpcBumpedByPlayerCallback(const std::string& npcId) {
if (!impl) return;
auto it = impl->npcBumpedByPlayerCallbacks.find(npcIndex);
auto it = impl->npcBumpedByPlayerCallbacks.find(npcId);
if (it == impl->npcBumpedByPlayerCallbacks.end()) return;
auto result = it->second();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_player_bumps_npc error for NPC " << npcIndex << ": " << err.what() << "\n";
std::cerr << "[SCRIPT] on_player_bumps_npc error for NPC " << npcId << ": " << err.what() << "\n";
}
}
@@ -1105,14 +1143,14 @@ namespace FRG {
}
}
void ScriptEngine::callNpcBumpsPlayerCallback(int npcIndex) {
void ScriptEngine::callNpcBumpsPlayerCallback(const std::string& npcId) {
if (!impl) return;
auto it = impl->npcBumpsPlayerCallbacks.find(npcIndex);
auto it = impl->npcBumpsPlayerCallbacks.find(npcId);
if (it == impl->npcBumpsPlayerCallbacks.end()) return;
auto result = it->second();
if (!result.valid()) {
sol::error err = result;
std::cerr << "[SCRIPT] on_npc_bumps_player error for NPC " << npcIndex << ": " << err.what() << "\n";
std::cerr << "[SCRIPT] on_npc_bumps_player error for NPC " << npcId << ": " << err.what() << "\n";
}
}
@@ -1177,18 +1215,4 @@ namespace FRG {
}
}
void ScriptEngine::rehydrateNamedCallback(const std::string& name, std::function<void()>& outCallback) const
{
outCallback = nullptr;
if (!impl || name.empty()) return;
sol::protected_function fn = impl->lua[name];
if (!fn.valid()) {
std::cerr << "[SCRIPT] rehydrateNamedCallback: global function '" << name
<< "' not found (script changed since save?)\n";
return;
}
outCallback = wrapLuaCallback(fn);
}
} // namespace FRG
+8 -16
View File
@@ -2,7 +2,6 @@
#include <string>
#include <memory>
#include <unordered_map>
#include <functional>
#include "quest/QuestJournal.h"
#include "external/nlohmann/json.hpp"
#include "ISaveable.h"
@@ -13,7 +12,7 @@ namespace FRG {
class Location;
class Inventory;
class ScriptEngine : public ISaveable {
class ScriptEngine {
public:
ScriptEngine();
~ScriptEngine();
@@ -25,23 +24,24 @@ public:
//void runScript(const std::string& path);
void callItemPickupCallback(const std::string& objectName);
void callFunction(const std::string& functionName);
void callActivateFunction(const std::string& functionName);
void callNpcInteractCallback(int npcIndex);
void callNpcInteractCallback(const std::string& npcId);
void callTriggerEnterCallback(const std::string& zoneId);
void callTriggerExitCallback(const std::string& zoneId);
// Fired when the player (moving) physically bumps into a standing NPC.
void callNpcBumpedByPlayerCallback(int npcIndex);
void callNpcBumpedByPlayerCallback(const std::string& npcId);
// Fired when a moving NPC physically bumps into the standing player.
void callNpcBumpsPlayerCallback(int npcIndex);
void callNpcBumpsPlayerCallback(const std::string& npcId);
void setGlobalStore(std::unordered_map<std::string, int>* store);
void setGlobalFloatStore(std::unordered_map<std::string, float>* store);
void setQuestJournal(Quest::QuestJournal* journal);
void callLocationInitCallback();
void callLocationEnterCallback();
void callLocationExitCallback();
@@ -63,16 +63,8 @@ public:
// has already run and registered all callbacks.
void loadScriptGlobals(const nlohmann::json& in);
// Re-wires an in-flight, one-shot callback (NPC/player walk-arrival, object
// animation-complete) that was serialized by name rather than by value.
// Looks up `name` as a global Lua function and assigns a wrapped invoker to
// outCallback; leaves outCallback empty and logs an error if not found.
// Call after init() (the script must already have run) and after the owning
// state (CharacterState / InteractiveObjectState) has been loaded.
void rehydrateNamedCallback(const std::string& name, std::function<void()>& outCallback) const;
void save(nlohmann::json& out) const override { saveScriptGlobals(out); }
void load(const nlohmann::json& in) override { loadScriptGlobals(in); }
void save(nlohmann::json& out) const { saveScriptGlobals(out); }
void load(const nlohmann::json& in) { loadScriptGlobals(in); }
private:
struct Impl;
+3 -3
View File
@@ -51,13 +51,13 @@ namespace FRG {
std::string shaderProgramName;
// Sparks spawn within this radius of each emission point. 0 means
// "spawn exactly at the point" (legacy behavior).
// "spawn exactly at the point" (old behavior).
float emissionRadius;
bool configured;
// Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity,
// particles are rendered as axis-aligned quads in world XY and sorted by
// world Z (legacy behavior). Otherwise quads are billboarded toward the
// world Z (old behavior). Otherwise quads are billboarded toward the
// camera using its world-space right/up axes, and particles are sorted
// back-to-front in eye space for correct alpha blending.
void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix);
@@ -106,7 +106,7 @@ namespace FRG {
// Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO.
// Pass the current camera view matrix (world → eye) to billboard sparks
// toward the camera and sort them back-to-front. Identity (default)
// keeps the legacy non-billboarded behavior.
// keeps the old non-billboarded behavior.
void prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix = Eigen::Matrix4f::Identity());
void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight);
+2 -2
View File
@@ -287,10 +287,10 @@ namespace FRG {
float x1, u1;
if (fillMode) {
const float clamped = std::clamp(value, 0.0f, 1.0f);
if (clamped <= 0.0f) {
/*if (clamped <= 0.0f) {
trackMesh.RefreshVBO();
return;
}
}*/
x1 = rect.x + rect.w * clamped;
u1 = clamped;
} else {
+3 -3
View File
@@ -14,7 +14,7 @@
namespace FRG::Dialogue {
class DialogueSystem : public FRG::ISaveable {
class DialogueSystem {
public:
bool init(Renderer& renderer, const std::string& zipFile = "");
@@ -48,8 +48,8 @@ public:
void saveDialogueState(nlohmann::json& out) const { dialogueRuntime.save(out); }
void loadDialogueState(const nlohmann::json& in) { dialogueRuntime.load(in); }
void save(nlohmann::json& out) const override { saveDialogueState(out); }
void load(const nlohmann::json& in) override { loadDialogueState(in); }
void save(nlohmann::json& out) const { saveDialogueState(out); }
void load(const nlohmann::json& in) { loadDialogueState(in); }
bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); }
bool isDialogueActive() const { return dialogueRuntime.isActive(); }
+227
View File
@@ -0,0 +1,227 @@
#pragma once
#include "../GameState.h"
#include "../external/nlohmann/json.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <variant>
#include <vector>
namespace FRG {
// Value-only view of authoritative state. It deliberately contains no runtime
// pointers, callbacks, renderer objects, or mutable references, so the same
// contract can later be transported over a network.
struct ChatMessageSnapshot {
std::string text;
bool incoming = false;
};
struct CharacterSnapshot {
std::string id;
CharacterState state;
};
struct InteractiveObjectSnapshot {
std::string id;
InteractiveObjectState state;
};
struct LocationSnapshot {
std::string id;
LocationState state;
std::unordered_map<std::string, CharacterSnapshot> npcs;
std::unordered_map<std::string, InteractiveObjectSnapshot> interactiveObjects;
std::unordered_map<std::string, TriggerZoneGameState> triggerZones;
std::unordered_map<std::string, bool> teleportZones;
nlohmann::json dialogue;
nlohmann::json scriptGlobals;
};
struct GameSnapshot {
std::uint64_t revision = 0;
std::string campaignId;
std::string currentLocationId;
bool isDarklands = false;
bool isNight = false;
bool isDawn = false;
float playerHp = 0.f;
float playerMaxHp = 0.f;
int money = 0;
TutorialStep tutorialStep = TutorialStep::Completed;
bool tutorialPhoneChatScreenOpened = false;
bool tutorialJournalScreenOpened = false;
bool tutorialNeedOpenTaxiScreen = false;
bool tutorialTaxiHintDone = false;
bool taxiIsCalled = false;
bool chatUnread[3] = {};
std::string chatPreview[3];
std::vector<ChatMessageSnapshot> chatHistory[3];
std::vector<std::string> inventoryItemIds;
nlohmann::json questJournal;
std::unordered_map<std::string, int> globalInts;
std::unordered_map<std::string, float> globalFloats;
std::unordered_map<std::string, CharacterSnapshot> players;
std::unordered_map<std::string, LocationSnapshot> locations;
std::vector<Quest::QuestState> quests;
bool isTutorialActive() const { return tutorialStep != TutorialStep::Completed; }
};
struct SetWorldModeCommand { bool darklands; bool night; bool dawn; };
struct SetCurrentLocationCommand { std::string locationId; };
struct SetPlayerHealthCommand { float hp; float maxHp; };
struct SetMoneyCommand { int amount; };
struct SpendMoneyCommand { int amount; };
struct CompleteTutorialStepCommand { TutorialStep step; };
struct SetTutorialStepCommand { TutorialStep step; };
struct SetTutorialFlagsCommand {
bool phoneChatOpened;
bool journalOpened;
bool needTaxiHint;
bool taxiHintDone;
};
struct SetChatCommand {
int index;
bool unread;
std::string preview;
};
struct AppendChatMessageCommand {
int index;
std::string text;
bool incoming;
};
struct SetTaxiCalledCommand { bool called; };
struct StartTaxiCommand { std::string locationId; };
struct AddInventoryItemCommand { std::string itemId; };
struct RemoveInventoryItemCommand { std::string itemId; };
struct SetGlobalIntCommand { std::string key; int value; };
struct SetGlobalFloatCommand { std::string key; float value; };
struct SetCharacterTransformCommand {
std::string locationId;
std::string characterId;
Eigen::Vector3f position;
float facingAngle;
};
struct SetCharacterTargetCommand {
std::string locationId;
std::string characterId;
std::string targetId;
};
struct ClearCharacterAttackTargetCommand {
std::string locationId;
std::string characterId;
};
struct SetCharacterDestinationCommand {
std::string locationId;
std::string characterId;
Eigen::Vector3f target;
std::string callbackName;
};
struct StopCharacterCommand {
std::string locationId;
std::string characterId;
};
struct FaceCharacterCommand {
std::string locationId;
std::string characterId;
std::string targetId;
};
struct ReplanCharacterCommand {
std::string locationId;
std::string characterId;
};
struct ResetCharacterAfterDeathCommand {
std::string locationId;
std::string characterId;
};
struct TryCharacterAttackComboCommand {
std::string locationId;
std::string characterId;
};
struct SetCharacterHealthCommand {
std::string locationId;
std::string characterId;
float hp;
};
struct SetCharacterAnimationMetadataCommand {
std::string locationId;
std::string characterId;
AnimationState animationState;
int startFrame;
int frameCount;
};
struct DamageCharacterCommand {
std::string locationId;
std::string characterId;
float amount;
std::string attackerId;
};
struct CommitCharacterStateCommand {
std::string locationId;
std::string characterId;
CharacterState state;
};
struct SetInteractiveObjectActiveCommand {
std::string locationId;
std::string objectId;
bool active;
};
struct SetTriggerZoneEnabledCommand {
std::string locationId;
std::string zoneId;
bool enabled;
};
struct ReloadQuestDefinitionsCommand { std::string path; std::string zipFile; };
using GameCommand = std::variant<
SetWorldModeCommand,
SetCurrentLocationCommand,
SetPlayerHealthCommand,
SetMoneyCommand,
SpendMoneyCommand,
CompleteTutorialStepCommand,
SetTutorialStepCommand,
SetTutorialFlagsCommand,
SetChatCommand,
AppendChatMessageCommand,
SetTaxiCalledCommand,
StartTaxiCommand,
AddInventoryItemCommand,
RemoveInventoryItemCommand,
SetGlobalIntCommand,
SetGlobalFloatCommand,
SetCharacterTransformCommand,
SetCharacterTargetCommand,
ClearCharacterAttackTargetCommand,
SetCharacterDestinationCommand,
StopCharacterCommand,
FaceCharacterCommand,
ReplanCharacterCommand,
ResetCharacterAfterDeathCommand,
TryCharacterAttackComboCommand,
SetCharacterHealthCommand,
SetCharacterAnimationMetadataCommand,
DamageCharacterCommand,
CommitCharacterStateCommand,
SetInteractiveObjectActiveCommand,
SetTriggerZoneEnabledCommand,
ReloadQuestDefinitionsCommand>;
class IGameServer {
public:
virtual ~IGameServer() = default;
// Each returned object is immutable and remains valid after later commands.
virtual std::shared_ptr<const GameSnapshot> snapshot() const = 0;
virtual void submit(const GameCommand& command) = 0;
virtual void update(std::int64_t deltaMs) = 0;
// Persistence stays server-side. The JSON shape is GameState's established
// save schema and is intentionally not the wire snapshot shape.
virtual void save(nlohmann::json& out) const = 0;
virtual void load(const nlohmann::json& in) = 0;
};
} // namespace FRG
+295
View File
@@ -0,0 +1,295 @@
#include "InternalGameServer.h"
#include "../Location.h"
#include <algorithm>
#include <type_traits>
namespace FRG {
InternalGameServer::InternalGameServer() = default;
GameSnapshot InternalGameServer::buildSnapshot() const
{
GameSnapshot result;
result.revision = revision_;
result.campaignId = state_.campaignId;
result.currentLocationId = state_.currentLocationName;
result.isDarklands = state_.isDarklands;
result.isNight = state_.isNight;
result.isDawn = state_.isDawn;
result.playerHp = state_.playerHp;
result.playerMaxHp = state_.playerMaxHp;
result.money = state_.money;
result.tutorialStep = state_.tutorialStep;
result.tutorialPhoneChatScreenOpened = state_.tutorialPhoneChatScreenOpened;
result.tutorialJournalScreenOpened = state_.tutorialJournalScreenOpened;
result.tutorialNeedOpenTaxiScreen = state_.tutorialNeedOpenTaxiScreen;
result.tutorialTaxiHintDone = state_.tutorialTaxiHintDone;
result.taxiIsCalled = state_.taxiIsCalled;
for (int i = 0; i < 3; ++i) {
result.chatUnread[i] = state_.chatUnread[i];
result.chatPreview[i] = state_.chatPreviewMsg[i];
result.chatHistory[i].reserve(state_.chatHistory[i].size());
for (const StoredChatMessage& message : state_.chatHistory[i])
result.chatHistory[i].push_back({message.text, message.incoming});
}
nlohmann::json inventoryJson;
state_.inventory.save(inventoryJson);
if (inventoryJson.contains("items") && inventoryJson["items"].is_array()) {
for (const auto& item : inventoryJson["items"])
if (item.is_string()) result.inventoryItemIds.push_back(item.get<std::string>());
}
state_.questJournal.save(result.questJournal);
result.globalInts = state_.globalInts;
result.globalFloats = state_.globalFloats;
for (const Quest::QuestState* quest : state_.questJournal.getVisibleQuests())
if (quest) result.quests.push_back(*quest);
for (const auto& [playerId, source] : state_.players) {
if (!source) continue;
CharacterState clean = *source;
result.players.emplace(playerId, CharacterSnapshot{playerId, std::move(clean)});
}
for (const auto& [locationId, source] : state_.locations) {
LocationSnapshot location;
location.id = locationId;
location.state = *source.location;
for (const auto& [id, sourceNpc] : source.npcs) {
if (!sourceNpc) continue;
CharacterState clean = *sourceNpc;
location.npcs.emplace(
id, CharacterSnapshot{id, std::move(clean)});
}
for (const auto& [id, sourceObject] : source.interactiveObjects) {
if (!sourceObject) continue;
InteractiveObjectState clean = *sourceObject;
location.interactiveObjects.emplace(
id, InteractiveObjectSnapshot{id, std::move(clean)});
}
location.triggerZones = source.triggerZones;
location.teleportZones = source.teleportZones;
location.dialogue = source.dialogue;
location.scriptGlobals = source.scriptGlobals;
result.locations.emplace(locationId, std::move(location));
}
return result;
}
std::shared_ptr<const GameSnapshot> InternalGameServer::snapshot() const
{
if (!cachedSnapshot_ || cachedRevision_ != revision_) {
cachedSnapshot_ = std::make_shared<const GameSnapshot>(buildSnapshot());
cachedRevision_ = revision_;
}
return cachedSnapshot_;
}
void InternalGameServer::submit(const GameCommand& command)
{
std::visit([this](const auto& value) {
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, SetWorldModeCommand>) {
state_.isDarklands = value.darklands;
state_.isNight = value.night;
state_.isDawn = value.dawn;
} else if constexpr (std::is_same_v<T, SetCurrentLocationCommand>) {
state_.currentLocationName = value.locationId;
} else if constexpr (std::is_same_v<T, SetPlayerHealthCommand>) {
state_.playerHp = value.hp;
state_.playerMaxHp = value.maxHp;
} else if constexpr (std::is_same_v<T, SetMoneyCommand>) {
state_.money = value.amount;
} else if constexpr (std::is_same_v<T, SpendMoneyCommand>) {
state_.money -= value.amount;
} else if constexpr (std::is_same_v<T, CompleteTutorialStepCommand>) {
state_.completeTutorialStep(value.step);
} else if constexpr (std::is_same_v<T, SetTutorialStepCommand>) {
state_.tutorialStep = value.step;
} else if constexpr (std::is_same_v<T, SetTutorialFlagsCommand>) {
state_.tutorialPhoneChatScreenOpened = value.phoneChatOpened;
state_.tutorialJournalScreenOpened = value.journalOpened;
state_.tutorialNeedOpenTaxiScreen = value.needTaxiHint;
state_.tutorialTaxiHintDone = value.taxiHintDone;
} else if constexpr (std::is_same_v<T, SetChatCommand>) {
if (value.index >= 0 && value.index < 3) {
state_.chatUnread[value.index] = value.unread;
state_.chatPreviewMsg[value.index] = value.preview;
}
} else if constexpr (std::is_same_v<T, AppendChatMessageCommand>) {
if (value.index >= 0 && value.index < 3) {
if (state_.chatHistory[value.index].size() >= 5)
state_.chatHistory[value.index].erase(state_.chatHistory[value.index].begin());
state_.chatHistory[value.index].push_back({value.text, value.incoming});
}
} else if constexpr (std::is_same_v<T, SetTaxiCalledCommand>) {
state_.taxiIsCalled = value.called;
} else if constexpr (std::is_same_v<T, StartTaxiCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end())
location->second.startTaxi();
} else if constexpr (std::is_same_v<T, AddInventoryItemCommand>) {
state_.inventory.addItem(value.itemId);
} else if constexpr (std::is_same_v<T, RemoveInventoryItemCommand>) {
state_.inventory.removeItem(value.itemId);
} else if constexpr (std::is_same_v<T, SetGlobalIntCommand>) {
state_.globalInts[value.key] = value.value;
} else if constexpr (std::is_same_v<T, SetGlobalFloatCommand>) {
state_.globalFloats[value.key] = value.value;
} else if constexpr (std::is_same_v<T, SetCharacterTransformCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) {
character->position = value.position;
character->facingAngle = value.facingAngle;
character->targetFacingAngle = value.facingAngle;
}
} else if constexpr (std::is_same_v<T, SetCharacterTargetCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->attackTargetId = value.targetId;
} else if constexpr (std::is_same_v<T, ClearCharacterAttackTargetCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->attackTargetId.clear();
} else if constexpr (std::is_same_v<T, SetCharacterDestinationCommand>) {
auto location = state_.locations.find(value.locationId);
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character && location != state_.locations.end() &&
location->second.location) {
character->setTarget(
value.target, value.callbackName,
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, StopCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->stopInPlace();
} else if constexpr (std::is_same_v<T, FaceCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character)
character->stopAndRotateToTarget(value.targetId);
} else if constexpr (std::is_same_v<T, ReplanCharacterCommand>) {
auto location = state_.locations.find(value.locationId);
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character && location != state_.locations.end() &&
location->second.location) {
character->forceReplan(
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, ResetCharacterAfterDeathCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->resetPlayerAfterDeath();
} else if constexpr (std::is_same_v<T, TryCharacterAttackComboCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->tryAttackCombo();
} else if constexpr (std::is_same_v<T, SetCharacterHealthCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->setHp(value.hp);
} else if constexpr (std::is_same_v<T, SetCharacterAnimationMetadataCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) {
character->setAnimationMetadata(
value.animationState, value.startFrame, value.frameCount);
}
} else if constexpr (std::is_same_v<T, DamageCharacterCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) character->hp = (std::max)(0.f, character->hp - (std::max)(0.f, value.amount));
} else if constexpr (std::is_same_v<T, CommitCharacterStateCommand>) {
CharacterState* character =
state_.findCharacter(value.locationId, value.characterId);
if (character) *character = value.state;
} else if constexpr (std::is_same_v<T, SetInteractiveObjectActiveCommand>) {
auto location = state_.locations.find(value.locationId);
if (location == state_.locations.end()) return;
auto object = location->second.interactiveObjects.find(value.objectId);
if (object != location->second.interactiveObjects.end() && object->second)
object->second->isActive = value.active;
} else if constexpr (std::is_same_v<T, SetTriggerZoneEnabledCommand>) {
auto location = state_.locations.find(value.locationId);
if (location != state_.locations.end()) {
location->second.triggerZones[value.zoneId].enabled = value.enabled;
auto runtime = runtimeLocations_.find(value.locationId);
if (runtime != runtimeLocations_.end() && runtime->second) {
for (auto& zone : runtime->second->triggerZones)
if (zone.id == value.zoneId) zone.enabled = value.enabled;
}
}
} else if constexpr (std::is_same_v<T, ReloadQuestDefinitionsCommand>) {
state_.questJournal.reloadDefinitionsPreservingState(value.path, value.zipFile);
}
}, command);
markHostMutation();
}
void InternalGameServer::update(std::int64_t deltaMs)
{
auto runtime = runtimeLocations_.find(state_.currentLocationName);
if (runtime == runtimeLocations_.end() || !runtime->second) return;
auto stored = state_.locations.find(state_.currentLocationName);
if (stored == state_.locations.end()) return;
const bool interactiveObjectsLocked =
stored->second.lockInteractiveObjectsDuringTutorial &&
state_.tutorialStep < TutorialStep::PickUpItem;
LocationSimulationEvents events =
stored->second.update(deltaMs, state_.players, interactiveObjectsLocked);
runtime->second->update(deltaMs, events);
synchronizeRuntimeAuxiliaryState();
}
void InternalGameServer::save(nlohmann::json& out) const
{
const_cast<InternalGameServer*>(this)->synchronizeRuntimeAuxiliaryState();
state_.save(out);
}
void InternalGameServer::load(const nlohmann::json& in)
{
state_.load(in);
applyStateToRuntime();
markHostMutation();
}
void InternalGameServer::bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location)
{
runtimeLocations_[id] = std::move(location);
auto stored = state_.locations.find(id);
if (stored != state_.locations.end() && stored->second.hasPersistentState) {
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
} else {
runtimeLocations_[id]->captureAuxiliaryState(state_.locations[id]);
}
markHostMutation();
}
void InternalGameServer::synchronizeRuntimeAuxiliaryState()
{
for (const auto& [id, location] : runtimeLocations_) {
if (!location) continue;
location->captureAuxiliaryState(state_.locations[id]);
}
markHostMutation();
}
void InternalGameServer::applyStateToRuntime()
{
for (const auto& [id, stored] : state_.locations) {
auto runtime = runtimeLocations_.find(id);
if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->applyAuxiliaryState(stored);
}
}
void InternalGameServer::markHostMutation()
{
++revision_;
cachedSnapshot_.reset();
}
} // namespace FRG
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "IGameServer.h"
namespace FRG {
class Game;
class Character;
class Location;
// In-process implementation used by single-player. GameState exists exactly
// once, here; clients see immutable snapshots and mutate it with typed commands.
class InternalGameServer final : public IGameServer {
public:
InternalGameServer();
std::shared_ptr<const GameSnapshot> snapshot() const override;
void submit(const GameCommand& command) override;
void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
private:
friend class Game;
// Transitional host operations are intentionally available only to Game,
// which owns location loading and old-save migration. They are never part of
// IGameServer and therefore cannot leak mutable state to UI/network clients.
GameState& hostState() { return state_; }
const GameState& hostState() const { return state_; }
std::unordered_map<std::string, std::shared_ptr<Location>>& runtimeLocations() { return runtimeLocations_; }
const std::unordered_map<std::string, std::shared_ptr<Location>>& runtimeLocations() const { return runtimeLocations_; }
void bindRuntimeLocation(const std::string& id, std::shared_ptr<Location> location);
void synchronizeRuntimeAuxiliaryState();
void applyStateToRuntime();
void markHostMutation();
GameSnapshot buildSnapshot() const;
GameState state_;
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
std::uint64_t revision_ = 1;
mutable std::uint64_t cachedRevision_ = 0;
mutable std::shared_ptr<const GameSnapshot> cachedSnapshot_;
};
} // namespace FRG
+67 -12
View File
@@ -8,6 +8,7 @@
#include <Eigen/Geometry>
#include "../Character.h"
#include <SDL.h>
#include <unordered_set>
namespace FRG {
@@ -91,6 +92,9 @@ namespace FRG {
for (const auto& item : j["objects"]) {
InteractiveObjectData data;
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("Interactive object id must not be empty in " + jsonPath);
data.base = parseGameObjectData(item);
data.interactionRadius = item.value("interactionRadius", 2.0f);
data.approachRadius = item.value("approachRadius", data.interactionRadius);
@@ -293,6 +297,7 @@ namespace FRG {
std::vector<InteractiveObject> GameObjectLoader::loadAndCreateInteractiveObjects(
const std::string& jsonPath,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath)
{
std::vector<InteractiveObject> interactiveObjects;
@@ -300,12 +305,35 @@ namespace FRG {
if (jsonPath.empty())
return interactiveObjects;
for (const auto& data : loadInteractiveFromJson(jsonPath, zipPath)) {
const auto definitions = loadInteractiveFromJson(jsonPath, zipPath);
std::unordered_set<std::string> configuredIds;
for (const auto& data : definitions) {
if (!configuredIds.insert(data.id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate interactive object id '" +
data.id + "' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (configuredIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved interactive object id '" + id +
"' is not present in " + jsonPath);
for (const auto& data : definitions) {
SDL_PumpEvents();
logger() << "Loading interactive object: " << data.base.name << std::endl;
try {
InteractiveObject intObj = InteractiveObject::createFromState(
data.toInteractiveObjectState(), renderer, zipPath);
InteractiveObjectState definition = data.toInteractiveObjectState();
auto& state = states[data.id];
if (!state) state = std::make_shared<InteractiveObjectState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
InteractiveObject intObj =
InteractiveObject::createFromState(*state, renderer, zipPath);
intObj.id = data.id;
intObj.loadedObject.name = data.base.name;
if (!data.activateFunctionName.empty())
@@ -338,7 +366,9 @@ namespace FRG {
for (const auto& item : j["npcs"]) {
NpcData data;
data.id = item.value("id", "npc_unknown");
data.id = item.at("id").get<std::string>();
if (data.id.empty())
throw std::runtime_error("NPC id must not be empty in " + jsonPath);
data.name = item.value("name", "Unknown NPC");
data.texturePath = item.value("texturePath", "");
if (item.contains("meshTextures") && item["meshTextures"].is_object()) {
@@ -376,6 +406,7 @@ namespace FRG {
logger() << "Successfully loaded " << npcs.size() << " NPCs from " << jsonPath << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error loading NPCs from JSON: " << e.what() << std::endl;
throw;
}
return npcs;
@@ -385,9 +416,10 @@ namespace FRG {
CharacterState st;
// Identity + game state
st.npcId = id;
st.npcName = name;
st.position = Eigen::Vector3f(positionX, positionY, positionZ);
st.homePosition = Eigen::Vector3f(positionX, 0.f, positionZ);
st.stopInPlace();
st.facingAngle = facingAngle;
st.walkSpeed = walkSpeed;
st.rotationSpeed = rotationSpeed;
@@ -396,6 +428,7 @@ namespace FRG {
st.initialHp = hp;
st.visibleRange = visibleRange;
st.canAttack = canAttack;
if (canAttack) st.attackTargetId = "player";
st.enabled = enabled;
st.interactionRadius = interactionRadius;
@@ -433,29 +466,51 @@ namespace FRG {
std::vector<std::unique_ptr<Character>> GameObjectLoader::loadAndCreate_Npcs(
const std::string& jsonPath,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath)
{
std::vector<std::unique_ptr<Character>> npcs;
int index = 0;
for (const auto& npcData : loadNpcsFromJson(jsonPath, zipPath)) {
const auto definitions = loadNpcsFromJson(jsonPath, zipPath);
std::unordered_set<std::string> seenIds;
for (const auto& definition : definitions) {
const std::string& id = definition.id;
if (id == "player" || !seenIds.insert(id).second)
throw std::runtime_error(
"GameObjectLoader: duplicate or reserved NPC id '" + id +
"' in " + jsonPath);
}
for (const auto& [id, state] : states)
if (seenIds.count(id) == 0)
throw std::runtime_error(
"GameObjectLoader: saved NPC id '" + id +
"' is not present in " + jsonPath);
for (const auto& npcData : definitions) {
SDL_PumpEvents();
logger() << "Loading NPC: " << npcData.name << std::endl;
try {
auto npc = Character::createFromState(npcData.toCharacterState(), renderer, zipPath.c_str());
CharacterState definition = npcData.toCharacterState();
auto& state = states[npcData.id];
if (!state) state = std::make_shared<CharacterState>(definition);
else {
nlohmann::json runtime;
state->save(runtime);
*state = std::move(definition);
state->load(runtime);
}
auto npc = Character::createFromState(
*state, server, locationId, npcData.id, renderer, zipPath.c_str());
if (npc) {
logger() << "Successfully loaded NPC: " << npcData.name << " at ("
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl;
npc->index = index;
npcs.push_back(std::move(npc));
index = index + 1;
}
} catch (const std::exception& e) {
std::cerr << "GameObjectLoader: Failed to load NPC '" << npcData.name << "': " << e.what() << std::endl;
}
}
logger() << "Total NPCs loaded: " << npcs.size() << std::endl;
return npcs;
}
+7 -1
View File
@@ -11,6 +11,7 @@
#include "../CharacterState.h"
namespace FRG {
class IGameServer;
class Character;
@@ -41,6 +42,7 @@ namespace FRG {
};
struct InteractiveObjectData {
std::string id;
GameObjectData base;
float interactionRadius = 2.0f;
float approachRadius = 2.0f;
@@ -105,13 +107,17 @@ namespace FRG {
static std::vector<InteractiveObject> loadAndCreateInteractiveObjects(
const std::string& jsonPath,
Renderer& renderer,
std::unordered_map<std::string, std::shared_ptr<InteractiveObjectState>>& states,
const std::string& zipPath = ""
);
static std::vector<std::unique_ptr<FRG::Character>> loadAndCreate_Npcs(
const std::string& jsonPath,
Renderer& renderer,
const std::string& zipPath = ""
std::unordered_map<std::string, std::shared_ptr<CharacterState>>& states,
IGameServer& server,
const std::string& locationId,
const std::string& zipPath
);
static std::vector<NpcData> loadNpcsFromJson(const std::string& jsonPath, const std::string& zipPath = "");
+63 -65
View File
@@ -19,12 +19,11 @@ namespace FRG {
using std::max;
#endif
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState st,
InteractiveObject InteractiveObject::createFromState(InteractiveObjectState& st,
Renderer& renderer,
const std::string& zipPath)
{
InteractiveObject obj;
obj.state = std::move(st);
InteractiveObject obj(st);
const LoadedGameObjectState& info = obj.state.objectInfo;
@@ -75,115 +74,115 @@ namespace FRG {
}
void InteractiveObject::moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::moveTo(
const Eigen::Vector3f& target, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Move;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = target;
task.targetRotY = state.rotationY;
task.targetScale = state.scale;
task.targetRotY = rotationY;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::rotateTo(
float targetRotY, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Rotate;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = state.position;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = targetRotY;
task.targetScale = state.scale;
task.targetScale = scale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::scaleTo(float targetScale, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::scaleTo(
float targetScale, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Scale;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.scale;
task.targetPos = state.position;
task.targetRotY = state.rotationY;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = scale;
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetScale;
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete, std::string callbackName) {
if (state.isAnimating) return;
void InteractiveObjectState::fadeTo(
float targetAlpha, float durationSec, std::string onCompleteName) {
if (isAnimating) return;
AnimTask task;
task.type = AnimTask::Type::Fade;
task.startPos = state.position;
task.startRotY = state.rotationY;
task.startScale = state.alpha; // reuse startScale to hold start alpha
task.targetPos = state.position;
task.targetRotY = state.rotationY;
task.startPos = position;
task.startRotY = rotationY;
task.startScale = alpha; // reuse startScale to hold start alpha
task.targetPos = position;
task.targetRotY = rotationY;
task.targetScale = targetAlpha; // reuse targetScale to hold target alpha
task.durationMs = durationSec * 1000.f;
task.elapsedMs = 0.f;
task.onComplete = std::move(onComplete);
task.onCompleteName = std::move(callbackName);
state.animTask = std::move(task);
state.isAnimating = true;
task.onCompleteName = std::move(onCompleteName);
animTask = std::move(task);
isAnimating = true;
}
void InteractiveObject::update(int64_t deltaMs) {
if (!state.isAnimating || !state.animTask) return;
InteractiveObjectUpdateEvents InteractiveObjectState::update(std::int64_t deltaMs) {
InteractiveObjectUpdateEvents events;
if (!isAnimating || !animTask) return events;
AnimTask& task = *state.animTask;
AnimTask& task = *animTask;
task.elapsedMs += static_cast<float>(deltaMs);
const float t = min(task.elapsedMs / task.durationMs, 1.0f);
const float t = (std::min)(task.elapsedMs / task.durationMs, 1.0f);
switch (task.type) {
case AnimTask::Type::Move:
state.position = task.startPos + (task.targetPos - task.startPos) * t;
position = task.startPos + (task.targetPos - task.startPos) * t;
break;
case AnimTask::Type::Rotate:
state.rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
rotationY = task.startRotY + (task.targetRotY - task.startRotY) * t;
break;
case AnimTask::Type::Scale:
state.scale = task.startScale + (task.targetScale - task.startScale) * t;
scale = task.startScale + (task.targetScale - task.startScale) * t;
break;
case AnimTask::Type::Fade:
state.alpha = task.startScale + (task.targetScale - task.startScale) * t;
alpha = task.startScale + (task.targetScale - task.startScale) * t;
break;
}
if (t >= 1.0f) {
state.position = task.targetPos;
state.rotationY = task.targetRotY;
position = task.targetPos;
rotationY = task.targetRotY;
if (task.type == AnimTask::Type::Fade)
state.alpha = task.targetScale;
alpha = task.targetScale;
else
state.scale = task.targetScale;
scale = task.targetScale;
std::function<void()> cb = std::move(task.onComplete);
state.animTask.reset();
state.isAnimating = false;
if (cb) cb();
events.onCompleteName = std::move(task.onCompleteName);
animTask.reset();
isAnimating = false;
}
return events;
}
void InteractiveObject::drawDarklands(Renderer& renderer) const {
@@ -295,7 +294,6 @@ namespace FRG {
task.durationMs = at.value("durationMs", 1000.f);
task.elapsedMs = at.value("elapsedMs", 0.f);
task.onCompleteName = at.value("onCompleteName", std::string());
// onComplete is non-serializable; re-wired at runtime by Lua script callbacks.
animTask = std::move(task);
}
}
+12 -12
View File
@@ -1,9 +1,6 @@
#pragma once
#include <string>
#include <memory>
#include <optional>
#include <functional>
#include <cstdint>
#include <Eigen/Core>
#include "render/Renderer.h"
#include "InteractiveObjectState.h"
@@ -21,25 +18,28 @@ namespace FRG {
};
struct InteractiveObject {
explicit InteractiveObject(InteractiveObjectState& boundState) : state(boundState) {}
std::string id;
LoadedGameObject loadedObject; // rendering assets
InteractiveObjectState state; // all mutable + creation state
// Factory: creates a fully-loaded InteractiveObject from a state descriptor.
// Loads textures and mesh from state.objectInfo, computes mesh centering,
// and sets state.position = jsonPosition + meshCenter.
static InteractiveObject createFromState(InteractiveObjectState st,
static InteractiveObject createFromState(InteractiveObjectState& state,
Renderer& renderer,
const std::string& zipPath = "");
// callbackName, if given, is the global Lua function name backing onComplete —
// stored in the AnimTask so it survives save/load (see ScriptEngine::rehydrateNamedCallback).
void moveTo(const Eigen::Vector3f& target, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void rotateTo(float targetRotY, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void scaleTo(float targetScale, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void fadeTo(float targetAlpha, float durationSec, std::function<void()> onComplete = {}, std::string callbackName = "");
void update(int64_t deltaMs);
void draw(Renderer& renderer) const;
void drawDarklands(Renderer& renderer) const;
const std::string& entityId() const { return id; }
private:
friend class GameObjectLoader;
friend class Location;
friend class LocationEditor;
friend class ScriptEngine;
friend class InternalGameServer;
InteractiveObjectState& state; // non-owning binding to LocationGameState
};
} // namespace FRG
+12 -5
View File
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <optional>
#include <functional>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include "external/nlohmann/json.hpp"
@@ -21,10 +21,11 @@ struct AnimTask {
float targetScale = 1.f;
float durationMs = 1000.f;
float elapsedMs = 0.f;
std::function<void()> onComplete; // non-serializable; re-wired at runtime by Lua callbacks
// Global Lua function name backing onComplete, if it was sourced from a script
// (see ScriptEngine::rehydrateNamedCallback). Serialized so the callback can be
// re-looked-up by name after a save/load; empty if none or non-Lua.
// Global Lua function called when the animation completes. Empty means none.
std::string onCompleteName;
};
struct InteractiveObjectUpdateEvents {
std::string onCompleteName;
};
@@ -70,6 +71,12 @@ public:
bool isAnimating = false;
std::optional<AnimTask> animTask;
void moveTo(const Eigen::Vector3f& target, float durationSec, std::string onCompleteName = {});
void rotateTo(float targetRotY, float durationSec, std::string onCompleteName = {});
void scaleTo(float targetScale, float durationSec, std::string onCompleteName = {});
void fadeTo(float targetAlpha, float durationSec, std::string onCompleteName = {});
InteractiveObjectUpdateEvents update(std::int64_t deltaMs);
// --- Serialisation (runtime mutable fields only) ---
void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override;
+4 -7
View File
@@ -3,6 +3,7 @@
#include "external/nlohmann/json.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include "../utils/Utils.h"
namespace FRG {
@@ -44,13 +45,9 @@ namespace FRG {
itemIds.clear();
if (!in.contains("items") || !in["items"].is_array()) return;
for (const auto& j : in["items"]) {
if (j.is_string()) {
itemIds.push_back(j.get<std::string>());
} else if (j.is_object()) {
// Backward-compat with old save files that stored full item structs.
const std::string id = j.value("id", "");
if (!id.empty()) itemIds.push_back(id);
}
if (!j.is_string())
throw std::runtime_error("Inventory item IDs must be strings");
itemIds.push_back(j.get<std::string>());
}
}
+6 -6
View File
@@ -101,12 +101,12 @@ namespace FRG {
}
std::vector<Eigen::Vector3f> route;
route.reserve(player.state.pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(player.state.position.x(), 0.0f, player.state.position.z()));
route.reserve(player.characterState.pathWaypoints.size() + 1);
route.push_back(Eigen::Vector3f(player.characterState.position.x(), 0.0f, player.characterState.position.z()));
const size_t firstWaypoint = (std::min)(
player.state.currentWaypointIndex, player.state.pathWaypoints.size());
for (size_t i = firstWaypoint; i < player.state.pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = player.state.pathWaypoints[i];
player.characterState.currentWaypointIndex, player.characterState.pathWaypoints.size());
for (size_t i = firstWaypoint; i < player.characterState.pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = player.characterState.pathWaypoints[i];
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
}
@@ -127,7 +127,7 @@ namespace FRG {
constexpr float kPlayerClearanceMs = 20.0f;
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
const float playerSpeed = (std::max)(
player.state.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
player.characterState.walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f);
const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f;
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
const float waveSpeed = totalLength / (revealDurationMs / 1000.0f);