From 3e9a0cbd34d64942498894235d709579a2f53e7f Mon Sep 17 00:00:00 2001 From: shweet Date: Thu, 12 Nov 2026 22:14:04 -0500 Subject: [PATCH] Split play into body, items, interactions, autofeed and camera, with events, commands and literal strings --- MODDING.md | 3 - src/anm2.cpp | 234 +++++----- src/anm2.h | 56 +-- src/body.cpp | 248 +++++++++++ src/character.cpp | 152 +++---- src/character.h | 187 +++++--- src/console.cpp | 51 ++- src/console.h | 16 +- src/dialogue.cpp | 393 +++++------------ src/dialogue.h | 6 +- src/interact.cpp | 289 +++++++++++++ src/items.cpp | 608 ++++++++------------------ src/main.cpp | 203 ++++----- src/play.cpp | 714 +++++++++++-------------------- src/play.h | 253 +++++++---- src/renderer.h | 2 + src/save.cpp | 176 +------- src/save.h | 130 +++++- src/strings.h | 106 ----- src/ui.cpp | 1039 +++++++++++++++++++++++++++------------------ src/ui.h | 69 +-- 21 files changed, 2504 insertions(+), 2431 deletions(-) create mode 100644 src/body.cpp create mode 100644 src/interact.cpp delete mode 100644 src/strings.h diff --git a/MODDING.md b/MODDING.md index 7071031..28ee5f9 100644 --- a/MODDING.md +++ b/MODDING.md @@ -206,9 +206,6 @@ Determines menu and general UI appearance and behavior. ### particles An object of particles by name, each with the `anm2` and `animation` it plays once where spawned. -### strings -Replaces UI text, by name. See [`src/strings.h`](src/strings.h) for every name and its default, e.g. `"strings": {"MenuTabInventory": "Pantry"}`. - # items.json Has all items and their parameters, alongside additional behavior. diff --git a/src/anm2.cpp b/src/anm2.cpp index 3d79b83..edd1dc1 100644 --- a/src/anm2.cpp +++ b/src/anm2.cpp @@ -174,6 +174,9 @@ void Anm2::load(const std::string& path, Renderer& renderer, Audio& audio) sounds = audio.sounds_get(folder, soundPaths); animations_parse(json_object(root, "animations"), animations, sheetRegions, layerSheets); + for (int i = 0; i < (int)animations.size(); i++) + animationIndices[animations[i].name] = i; + bounds.assign(animations.size(), {NAN, NAN, NAN, NAN}); defaultAnimation = std::max(0, animation_get(json_string(root, "defaultAnimation"))); } @@ -185,10 +188,8 @@ static int name_get(const std::vector& names, const std::string& na int Anm2::animation_get(const std::string& name) const { - for (int i = 0; i < (int)animations.size(); i++) - if (animations[i].name == name) - return i; - return -1; + auto found = animationIndices.find(name); + return found == animationIndices.end() ? -1 : found->second; } int Anm2::event_get(const std::string& name) const @@ -230,11 +231,86 @@ static float ease(float t, Easing easing) } } +// The track's keyframe at a time, blended toward the next one if it eases. Past the end the last keyframe holds. An +// empty track gives an invisible frame. +Anm2Frame track_sample(const Anm2Track& track, float time) +{ + Anm2Frame frame; + frame.isVisible = !track.empty(); + time = std::max(time, 0.0f); + float start = 0; + for (size_t i = 0; i < track.size(); i++) + { + frame = track[i]; + if (time >= start + frame.delay && i + 1 < track.size()) + { + start += frame.delay; + continue; + } + if (frame.easing != EASING_NONE && i + 1 < track.size() && frame.delay > 1) + { + const Anm2Frame& next = track[i + 1]; + float t = ease((time - start) / frame.delay, frame.easing); + frame.position = vec2_lerp(frame.position, next.position, t); + frame.scale = vec2_lerp(frame.scale, next.scale, t); + frame.rotation = std::lerp(frame.rotation, next.rotation, t); + frame.tint = color_lerp(frame.tint, next.tint, t); + frame.offset = color_lerp(frame.offset, next.offset, t); + } + break; + } + return frame; +} + +static Affine root_affine_get(const Anm2Frame& frame, Vec2 position = {}) +{ + return affine_get(frame.position + position, frame.rotation, frame.scale / PERCENT); +} + +static Affine layer_affine_get(const Anm2Frame& frame) +{ + return affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot); +} + +static Affine null_affine_get(const Anm2Frame& frame) +{ + return affine_get(frame.position, frame.rotation, {1, 1}, frame.scale / 2); +} + +// The smallest rectangle holding every visible layer over a whole animation, around its origin. Worked out once. +Rect Anm2::bounds_get(int index) +{ + if (index < 0 || index >= (int)animations.size()) + return {NAN, NAN, NAN, NAN}; + if (is_rect_valid(bounds[index])) + return bounds[index]; + Anm2Animation& animation = animations[index]; + Actor actor(this, {}, animation.name); + for (int time = 0; time < animation.length; time++) + { + Affine root = root_affine_get(track_sample(animation.root, (float)time)); + for (Anm2Item& layer : animation.layers) + { + Affine model = root; + Color tint; + Color offset; + Anm2Frame frame = track_sample(layer.frames, (float)time); + if (layer.isVisible && frame.isVisible && frame.size.x > 0 && + actor.is_group_visible(animation, layer.group, (float)time, model, tint, offset)) + bounds[index] = rect_merge(bounds[index], affine_bounds(model * layer_affine_get(frame), frame.size.x, + frame.size.y)); + } + } + return bounds[index]; +} + Actor::Actor(Anm2* anm2, Vec2 position, const std::string& name) : anm2(anm2), position(position) { if (!anm2) return; sheets = anm2->sheets; + layers.resize(anm2->layers.size()); + nullScales.resize(anm2->nulls.size()); int index = anm2->animation_get(name); request_apply({anm2->animations[index >= 0 ? index : anm2->defaultAnimation].name, TRANSITION_FORCE}); } @@ -247,8 +323,7 @@ Anm2Animation* Actor::animation_get(int index) bool Actor::is_playing(const std::string& name) { - Anm2Animation* current = animation_get(); - return isPlaying && current && current->name == name; + return isPlaying && anm2 && animation >= 0 && anm2->animation_get(name) == animation; } bool Actor::is_finished() @@ -268,8 +343,7 @@ void Actor::request_apply(const Request& request) int index = anm2 ? anm2->animation_get(request.animation) : -1; if (index < 0) { - static std::unordered_set missing; - if (anm2 && missing.insert(request.animation).second) + if (anm2 && anm2->missing.insert(request.animation).second) SDL_Log("Animation \"%s\" does not exist", request.animation.c_str()); return; } @@ -289,23 +363,20 @@ void Actor::request_apply(const Request& request) triggersPlayed.assign(anm2->animations[index].triggers.size(), false); } +void Actor::pulse_add(int layer, float amplitude, float cycles, float duration) +{ + if (layer >= 0 && layer < (int)layers.size() && duration > 0) + pulses.push_back({amplitude, cycles, duration, duration, layer}); +} + void Actor::update(Audio& audio) { if (pending) request_apply(*pending); pending.reset(); - - for (int i = 0; i < (int)overrides.size(); i++) - { - Override& override = overrides[i]; - if (override.timeStart <= 0) - continue; - float percent = std::clamp(override.time / override.timeStart, 0.0f, 1.0f); - float wave = std::cos((1 - percent) * 2 * PI * override.cycles) * percent; - override.scale = {std::abs(override.amplitude.x) * wave, std::abs(override.amplitude.y) * wave}; - if (--override.time <= 0) - overrides.erase(overrides.begin() + i--); - } + for (int i = 0; i < (int)pulses.size(); i++) + if (--pulses[i].time <= 0) + pulses.erase(pulses.begin() + i--); Anm2Animation* current = animation_get(); if (!isPlaying || (current && current->isLoop)) @@ -339,62 +410,50 @@ void Actor::update(Audio& audio) triggersPlayed.assign(triggersPlayed.size(), false); } -// The track's keyframe at a time, blended toward the next one if it eases, then changed by any overrides. Past the end -// the last keyframe holds. An empty track gives an invisible frame. -Anm2Frame Actor::frame_get(const Anm2Track& track, float frameTime, OverrideTarget target, int id) +// A layer's keyframe at a time, with its swapped region, added scale and pulses. +Anm2Frame Actor::layer_frame_get(const Anm2Item& layer, float frameTime) { - Anm2Frame frame; - if (track.empty()) - { - frame.isVisible = false; + Anm2Frame frame = track_sample(layer.frames, frameTime); + if (layer.id < 0 || layer.id >= (int)layers.size()) return frame; - } - frameTime = std::max(frameTime, 0.0f); - float start = 0; - for (size_t i = 0; i < track.size(); i++) + LayerChange& change = layers[layer.id]; + if (change.isRegion) { - frame = track[i]; - if (frameTime >= start + frame.delay && i + 1 < track.size()) - { - start += frame.delay; - continue; - } - if (frame.easing != EASING_NONE && i + 1 < track.size() && frame.delay > 1) - { - const Anm2Frame& next = track[i + 1]; - float t = ease((frameTime - start) / frame.delay, frame.easing); - frame.position = vec2_lerp(frame.position, next.position, t); - frame.scale = vec2_lerp(frame.scale, next.scale, t); - frame.rotation = std::lerp(frame.rotation, next.rotation, t); - frame.tint = color_lerp(frame.tint, next.tint, t); - frame.offset = color_lerp(frame.offset, next.offset, t); - } - break; + frame.crop = change.crop; + frame.size = change.size; + frame.pivot = change.pivot; } - for (Override& override : overrides) + frame.scale = frame.scale + change.scale; + for (Pulse& pulse : pulses) { - if (override.target != target || override.id != id) + if (pulse.layer != layer.id) continue; - if (override.isRegion) - { - frame.crop = override.crop; - frame.size = override.size; - frame.pivot = override.pivot; - } - if (override.isRotation) - frame.rotation = override.rotation; - frame.scale = frame.scale + override.scale; + float percent = pulse.time / pulse.duration; + float wave = std::abs(pulse.amplitude) * std::cos((1 - percent) * 2 * PI * pulse.cycles) * percent; + frame.scale = frame.scale + Vec2{wave, wave}; } return frame; } -static Affine root_affine_get(const Anm2Frame& frame, Vec2 position = {}) +Anm2Frame Actor::null_frame_get(const Anm2Item& null, float frameTime) { - return affine_get(frame.position + position, frame.rotation, frame.scale / PERCENT); + Anm2Frame frame = track_sample(null.frames, frameTime); + if (null.id >= 0 && null.id < (int)nullScales.size()) + frame.scale = frame.scale + nullScales[null.id]; + return frame; +} + +Anm2Frame Actor::root_frame_get(Anm2Animation& current, float frameTime) +{ + Anm2Frame frame = track_sample(current.root, frameTime); + if (rotation) + frame.rotation = *rotation; + return frame; } // The transform, tint and color offset a group (and its parents) give its items, or false if any is hidden. -bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime, Affine& model, Color& tint, Color& offset) +bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime, Affine& model, Color& tint, + Color& offset) { if (group < 0 || group >= (int)current.groups.size()) return true; @@ -403,24 +462,19 @@ bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime, return false; if (entry.root.empty()) return true; - Anm2Frame frame = frame_get(entry.root, frameTime); + Anm2Frame frame = track_sample(entry.root, frameTime); model = model * root_affine_get(frame); tint = tint * frame.tint; offset = offset + frame.offset; return frame.isVisible; } -static Affine null_affine_get(const Anm2Frame& frame) -{ - return affine_get(frame.position, frame.rotation, {1, 1}, frame.scale / 2); -} - void Actor::render(Renderer& renderer, Affine view) { Anm2Animation* current = animation_get(); if (!isVisible || !current) return; - Anm2Frame root = frame_get(current->root, time); + Anm2Frame root = root_frame_get(*current, time); Affine rootModel = view * root_affine_get(root, position); for (Anm2Item& layer : current->layers) { @@ -429,25 +483,25 @@ void Actor::render(Renderer& renderer, Affine view) Color offset = root.offset; if (!layer.isVisible || !is_group_visible(*current, layer.group, time, model, tint, offset)) continue; - Anm2Frame frame = frame_get(layer.frames, time, TARGET_LAYER, layer.id); + Anm2Frame frame = layer_frame_get(layer, time); int sheet = layer.id >= 0 && layer.id < (int)anm2->layerSheets.size() ? anm2->layerSheets[layer.id] : -1; Texture* texture = sheet >= 0 && sheet < (int)sheets.size() ? sheets[sheet] : nullptr; if (!frame.isVisible || !texture) continue; - model = model * affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot); + model = model * layer_affine_get(frame); renderer.quad_draw(*texture, {frame.crop.x, frame.crop.y, frame.size.x, frame.size.y}, model, frame.tint * tint, frame.offset + offset); - if (isShowLayers) + if (renderer.isShowLayers) renderer.outline_draw(model, frame.size.x, frame.size.y, LAYER_COLOR); } - if (!isShowNulls) + if (!renderer.isShowNulls) return; for (Anm2Item& null : current->nulls) { Affine model = rootModel; Color tint; Color offset; - Anm2Frame frame = frame_get(null.frames, time, TARGET_NULL, null.id); + Anm2Frame frame = null_frame_get(null, time); if (null.isVisible && frame.isVisible && is_group_visible(*current, null.group, time, model, tint, offset)) renderer.outline_draw(model * null_affine_get(frame), frame.scale.x, frame.scale.y, NULL_COLOR); } @@ -464,10 +518,10 @@ Rect Actor::null_rect_get(int null, int index, float frameTime) { if (item.id != null) continue; - Affine model = root_affine_get(frame_get(current->root, frameTime), position); + Affine model = root_affine_get(root_frame_get(*current, frameTime), position); Color tint; Color offset; - Anm2Frame frame = frame_get(item.frames, frameTime, TARGET_NULL, null); + Anm2Frame frame = null_frame_get(item, frameTime); if (!frame.isVisible || !is_group_visible(*current, item.group, frameTime, model, tint, offset)) break; return affine_bounds(model * null_affine_get(frame), frame.scale.x, frame.scale.y); @@ -475,28 +529,12 @@ Rect Actor::null_rect_get(int null, int index, float frameTime) return {NAN, NAN, NAN, NAN}; } -// The smallest rectangle holding every visible layer over the whole current animation. +// The current animation's bounds (see Anm2::bounds_get) where the actor stands, turned by its set rotation. Rect Actor::bounds_get() { - Rect bounds = {NAN, NAN, NAN, NAN}; - Anm2Animation* current = animation_get(); - if (!current) - return bounds; - for (int frameTime = 0; frameTime < current->length; frameTime++) - { - Affine rootModel = root_affine_get(frame_get(current->root, (float)frameTime), position); - for (Anm2Item& layer : current->layers) - { - Affine model = rootModel; - Color tint; - Color offset; - Anm2Frame frame = frame_get(layer.frames, (float)frameTime, TARGET_LAYER, layer.id); - if (!layer.isVisible || !frame.isVisible || frame.size.x <= 0 || - !is_group_visible(*current, layer.group, (float)frameTime, model, tint, offset)) - continue; - model = model * affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot); - bounds = rect_merge(bounds, affine_bounds(model, frame.size.x, frame.size.y)); - } - } - return bounds; + Rect local = anm2 ? anm2->bounds_get(animation) : Rect{NAN, NAN, NAN, NAN}; + if (!is_rect_valid(local)) + return local; + Affine model = affine_get(position, rotation.value_or(0), {1, 1}) * Affine{1, 0, 0, 1, local.x, local.y}; + return affine_bounds(model, local.w, local.h); } diff --git a/src/anm2.h b/src/anm2.h index 4ad83f4..b31ad85 100644 --- a/src/anm2.h +++ b/src/anm2.h @@ -4,6 +4,8 @@ #include "renderer.h" #include #include +#include +#include #include enum Easing @@ -75,10 +77,13 @@ struct Anm2Animation class Anm2 { public: + std::unordered_map animationIndices; + std::unordered_set missing; std::vector animations; std::vector events; std::vector layers; std::vector nulls; + std::vector bounds; std::vector sheets; std::vector layerSheets; Sounds sounds; @@ -90,9 +95,11 @@ public: int event_get(const std::string& name) const; int layer_get(const std::string& name) const; int null_get(const std::string& name) const; + Rect bounds_get(int animation); }; int track_length_get(const Anm2Track& track); +Anm2Frame track_sample(const Anm2Track& track, float time); enum Transition { @@ -108,30 +115,24 @@ enum Interrupt INTERRUPT_NEVER }; -enum OverrideTarget +// How an actor changes one of its layers: a region shown in place of its own, and scale added on. +struct LayerChange { - TARGET_ROOT, - TARGET_LAYER, - TARGET_NULL -}; - -// Changes one track's frames as an actor draws them: a swapped region, an added scale (oscillating out over time, when -// timed) or a set rotation. -struct Override -{ - Vec2 amplitude; Vec2 crop; Vec2 pivot; Vec2 scale; Vec2 size; - float cycles = 0; - float rotation = 0; - float time = 0; - float timeStart = 0; - int id = -1; - OverrideTarget target = TARGET_LAYER; bool isRegion = false; - bool isRotation = false; +}; + +// Scale added to a layer that oscillates and fades out over its duration, in ticks. +struct Pulse +{ + float amplitude = 0; + float cycles = 1; + float duration = 0; + float time = 0; + int layer = -1; }; struct Request @@ -141,17 +142,18 @@ struct Request Interrupt interrupt = INTERRUPT_IF_ALLOWED; }; -// Plays an Anm2's animations by name at a position. A static actor holds its animation's first frame. +// Plays an Anm2's animations by name at a position. A static actor holds its animation's first frame. Locked, it ignores +// requests to play. class Actor { public: - static inline bool isShowLayers = false; - static inline bool isShowNulls = false; - Anm2* anm2 = nullptr; std::optional pending; - std::vector overrides; + std::optional rotation; + std::vector layers; + std::vector pulses; std::vector sheets; + std::vector nullScales; std::vector triggersPlayed; Vec2 position; float time = 0; @@ -167,16 +169,20 @@ public: Actor(Anm2* anm2, Vec2 position = {}, const std::string& animation = ""); void play(const std::string& name, Transition transition = TRANSITION_NORMAL, Interrupt interrupt = INTERRUPT_IF_ALLOWED); + void pulse_add(int layer, float amplitude, float cycles, float duration); void update(Audio& audio); void render(Renderer& renderer, Affine view); Anm2Animation* animation_get(int index = -1); - Anm2Frame frame_get(const Anm2Track& track, float frameTime, OverrideTarget target = TARGET_ROOT, int id = -1); + Anm2Frame layer_frame_get(const Anm2Item& layer, float frameTime); Rect null_rect_get(int null, int index = -1, float frameTime = -1); Rect bounds_get(); bool is_playing(const std::string& name); bool is_finished(); + bool is_group_visible(Anm2Animation& animation, int group, float frameTime, Affine& model, Color& tint, + Color& offset); private: void request_apply(const Request& request); - bool is_group_visible(Anm2Animation& animation, int group, float frameTime, Affine& model, Color& tint, Color& offset); + Anm2Frame null_frame_get(const Anm2Item& null, float frameTime); + Anm2Frame root_frame_get(Anm2Animation& animation, float frameTime); }; diff --git a/src/body.cpp b/src/body.cpp new file mode 100644 index 0000000..5f031fa --- /dev/null +++ b/src/body.cpp @@ -0,0 +1,248 @@ +#include "play.h" + +constexpr float SWAP_STEP = 0.5f; + +enum SwapMode +{ + SWAP_LOOP, + SWAP_ONCE +}; + +// Keeps a value within a range given either way round, putting what isn't a number at its low end. +static float value_clamp(float value, float low, float high) +{ + if (low > high) + std::swap(low, high); + return std::isfinite(value) ? std::clamp(value, low, high) : low; +} + +// Starts a body from the character's traits, keeping any stats already loaded into it. +void Body::set(Character& played, Audio& sound, Game game) +{ + character = &played; + audio = &sound; + actor = Actor(played.anm2); + Traits& traits = played.traits; + if (stats.weight <= 0) + { + stats.weight = traits.weight; + stats.capacity = traits.capacity; + stats.digestionRate = traits.digestionRate; + } + bool isRolled = game == GAME_NEW && played.alternate.texture && is_chance(played.alternate.chance); + stats.isAlternate |= isRolled; + if (isRolled) + audio->play(played.alternate.sound); + if (stats.isAlternate && played.alternate.sheet < (int)actor.sheets.size()) + actor.sheets[played.alternate.sheet] = played.alternate.texture; + values_clamp(); + stage_set(stage_get(stats.weight)); + idle_play(); + actor.update(*audio); +} + +void Body::stage_set(int index) +{ + stage = index; + suffix = stage > 0 && stage <= (int)character->stages.size() ? character->stages[stage - 1].animationSuffix : ""; +} + +float Body::capacity_max_get() +{ + return stats.capacity * character->traits.overstuffedMultiplier; +} + +float Body::capacity_percent_get() +{ + float capacityMax = capacity_max_get(); + return capacityMax > 0 ? stats.calories / capacityMax : 0; +} + +bool Body::is_over_capacity() +{ + return stats.calories > stats.capacity; +} + +int Body::stage_get(float weight) +{ + std::vector& stages = character->stages; + if (stages.empty() || weight <= character->traits.weight) + return 0; + for (int i = 0; i < (int)stages.size(); i++) + if (weight < stages[i].weight) + return i; + return (int)stages.size(); +} + +float Body::stage_threshold_get(int index) +{ + std::vector& stages = character->stages; + if (index <= 0 || stages.empty()) + return character->traits.weight; + return stages[std::min(index, (int)stages.size()) - 1].weight; +} + +float Body::stage_progress_get() +{ + float low = stage_threshold_get(stage); + float high = stage_threshold_get(stage + 1); + if (stage >= (int)character->stages.size() || high <= low) + return 1; + return (stats.weight - low) / (high - low); +} + +std::string Body::idle_get() +{ + if (isStageUp) + return ""; + if (!idleOverride.empty()) + return idleOverride; + Animations& animations = character->animations; + return is_over_capacity() && !animations.idleFull.empty() ? animations.idleFull : animations.idle; +} + +// Plays one of the character's animations, as its current stage draws it. +void Body::play(const std::string& animation, Transition transition, Interrupt interrupt) +{ + if (!animation.empty()) + actor.play(animation + suffix, transition, interrupt); +} + +void Body::idle_play() +{ + play(idle_get()); +} + +void Body::idle_override_set(const std::string& animation) +{ + if (!animation.empty()) + idleOverride = animation == character->animations.idle ? "" : animation; +} + +// Where an eat area takes food: its null now, or at the start of its animation when it isn't showing. +Rect Body::eat_rect_get(const EatArea& eatArea) +{ + Rect rect = actor.null_rect_get(eatArea.null); + if (!is_rect_valid(rect)) + rect = actor.null_rect_get(eatArea.null, actor.anm2->animation_get(eatArea.animation + suffix), 0); + return rect; +} + +void Body::values_clamp() +{ + Traits& traits = character->traits; + stats.weight = value_clamp(stats.weight, traits.weight, traits.weightMax); + stats.capacity = value_clamp(stats.capacity, traits.capacityMin, traits.capacityMax); + stats.digestionRate = value_clamp(stats.digestionRate, traits.digestionRateMin, traits.digestionRateMax); + stats.calories = value_clamp(stats.calories, 0, std::max(0.0f, capacity_max_get())); + stats.digestionProgress = value_clamp(stats.digestionProgress, 0, DIGESTION_MAX); + stats.digestionTimer = std::clamp(stats.digestionTimer, 0, std::max(0, traits.digestionTime)); + stats.totalCalories = std::max(0.0f, stats.totalCalories); + stats.playtime = std::max(0.0f, stats.playtime); +} + +// Shows a swap's source layer on its destination, stepping through the source's frames while active, over and over or +// once. Turns itself off (and returns false) when the animation lacks the source, or a single pass ends. +static bool is_swapping(Actor& actor, Swap swap, float& time, bool isActive, SwapMode mode) +{ + Anm2Animation* animation = actor.animation_get(); + bool isDestination = swap.destination >= 0 && swap.destination < (int)actor.layers.size(); + if (isDestination) + actor.layers[swap.destination].isRegion = false; + if (!isActive || !animation || !isDestination) + return false; + for (Anm2Item& layer : animation->layers) + { + if (layer.id != swap.source || layer.frames.empty()) + continue; + Anm2Frame frame = actor.layer_frame_get(layer, time); + LayerChange& change = actor.layers[swap.destination]; + change = {frame.crop, frame.pivot, change.scale, frame.size, true}; + time += SWAP_STEP; + if (time < track_length_get(layer.frames)) + return true; + time = 0; + return mode == SWAP_LOOP; + } + time = 0; + return false; +} + +// Digests, grows, gurgles, and keeps the body's animations up: idle, stage ups, swelling, talking and blinking. +void Body::update(std::vector& events) +{ + Traits& traits = character->traits; + values_clamp(); + if (!actor.isPlaying) + { + if (isStageUp) + { + events.push_back(stage >= (int)character->stages.size() ? EVENT_STAGE_FINAL : EVENT_STAGE_UP); + isStageUp = false; + actor.isLocked = false; + } + if (!isTalking) + idle_play(); + } + actor.update(*audio); + + if (stats.isDigesting && --stats.digestionTimer <= 0) + { + float nextWeight = + value_clamp(stats.weight + stats.calories * traits.caloriesToKilogram, traits.weight, traits.weightMax); + if (is_over_capacity()) + { + float over = stats.calories - stats.capacity; + float overPercent = over / (capacity_max_get() - stats.capacity); + stats.capacity = value_clamp(stats.capacity + overPercent * traits.overstuffedDigestBonus * over, + traits.capacityMin, traits.capacityMax); + } + stats.calories = 0; + if (int nextStage = stage_get(nextWeight); nextStage > stage_get(stats.weight)) + { + actor.interrupt = INTERRUPT_IF_ALLOWED; + play(character->animations.stageUp, TRANSITION_NORMAL, INTERRUPT_NEVER); + stage_set(nextStage); + isStageUp = true; + actor.isLocked = true; + } + else + events.push_back(EVENT_DIGESTED); + stats.weight = nextWeight; + stats.isDigesting = false; + stats.digestionTimer = traits.digestionTime; + stats.digestionProgress = 0; + } + else if (!stats.isDigesting) + { + if (stats.calories > 0) + stats.digestionProgress += stats.digestionRate; + if (stats.digestionProgress >= DIGESTION_MAX) + { + stats.isDigesting = true; + stats.digestionTimer = traits.digestionTime; + audio->play(character->sounds[SOUND_DIGEST]); + } + } + values_clamp(); + if (is_chance(traits.gurgleChance * capacity_percent_get() * traits.gurgleCapacityMultiplier * PERCENT)) + audio->play(character->sounds[SOUND_GURGLE]); + if (int current = stage_get(stats.weight); current != stage) + stage_set(current); + + float stageProgress = stage_progress_get(); + float capacityProgress = capacity_percent_get(); + if (stats.isDigesting) + capacityProgress *= (float)stats.digestionTimer / std::max(1, traits.digestionTime); + for (ExpandArea& expandArea : character->expandAreas) + { + float add = std::clamp((stageProgress + capacityProgress) * expandArea.scaleMax / 2, 0.0f, expandArea.scaleMax); + if (expandArea.layer >= 0) + actor.layers[expandArea.layer].scale = {add, add}; + if (expandArea.null >= 0) + actor.nullScales[expandArea.null] = {add, add}; + } + + isTalking = is_swapping(actor, character->talk, talkTime, isTalking, SWAP_LOOP); + isBlinking = is_swapping(actor, character->blink, blinkTime, isBlinking || is_chance(traits.blinkChance), SWAP_ONCE); +} diff --git a/src/character.cpp b/src/character.cpp index 302e5e5..8d2e85c 100644 --- a/src/character.cpp +++ b/src/character.cpp @@ -6,6 +6,11 @@ constexpr float FONT_SIZE = 20; constexpr const char* EFFECT_NAMES[] = {"animation", "big", "color", "delay", "oscillation", "scene", "screenshake", "shake", "silent", "small", "sound"}; constexpr const char* USE_NAMES[] = {"", "edible", "tool"}; +constexpr const char* HOOK_NAMES[] = { +#define X(id, name) name, + HOOKS(X) +#undef X +}; struct SoundSource { @@ -42,13 +47,23 @@ Entry* Dialogue::pool_get(const std::string& pool) return entry_get(found->second[SDL_rand((Sint32)found->second.size())]); } -Entry* Dialogue::hook_get(const std::string& hook) +Entry* Dialogue::hook_get(Hook hook) { - auto found = hooks.find(hook); - if (found == hooks.end()) - return nullptr; - Entry* entry = pool_get(found->second); - return entry ? entry : entry_get(found->second); + Entry* entry = pool_get(hooks[hook]); + return entry ? entry : entry_get(hooks[hook]); +} + +bool Dialogue::is_hook(Hook hook) +{ + return pools.contains(hooks[hook]) || entries.contains(hooks[hook]); +} + +int Catalog::item_get(const std::string& name) +{ + for (int i = 0; i < (int)items.size(); i++) + if (items[i].name == name) + return i; + return -1; } void Character::preview_load(const std::string& path, Renderer& renderer) @@ -65,7 +80,7 @@ void Character::preview_load(const std::string& path, Renderer& renderer) description = json_string(root, "description"); credits = json_string(root, "credits"); color = json_color(root, "color", color); - weight = json_float(root, "weight"); + traits.weight = json_float(root, "weight"); anm2Path = json_string(root, "anm2"); portrait = root["portrait"].error() ? nullptr : renderer.texture_get(folder + json_string(root, "portrait")); render = root["render"].error() ? nullptr : renderer.texture_get(folder + json_string(root, "render")); @@ -120,35 +135,34 @@ static void interact_parse(object source, InteractArea& area, Character& charact static void character_parse(object root, Character& character, Renderer& renderer, Audio& audio) { character.anm2 = character.anm2_get(character.anm2Path, renderer, audio); - character.weightMax = json_float(root, "weightMax", character.weight); - character.capacity = json_float(root, "capacity"); - character.capacityMin = json_float(root, "capacityMin"); - character.capacityMax = json_float(root, "capacityMax", character.capacity); - character.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1); - character.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus"); - character.caloriesToKilogram = json_float(root, "caloriesToKilogram"); - character.digestionRate = json_float(root, "digestionRate") / TICK_RATE; - character.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE; - character.digestionRateMax = json_float(root, "digestionRateMax", character.digestionRate * TICK_RATE) / TICK_RATE; - character.digestionTime = json_int(root, "digestionTime"); - character.blinkChance = json_float(root, "blinkChance"); - character.gurgleChance = json_float(root, "gurgleChance"); - character.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier"); + Traits& traits = character.traits; + traits.weightMax = json_float(root, "weightMax", traits.weight); + traits.capacity = json_float(root, "capacity"); + traits.capacityMin = json_float(root, "capacityMin"); + traits.capacityMax = json_float(root, "capacityMax", traits.capacity); + traits.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1); + traits.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus"); + traits.caloriesToKilogram = json_float(root, "caloriesToKilogram"); + traits.digestionRate = json_float(root, "digestionRate") / TICK_RATE; + traits.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE; + traits.digestionRateMax = json_float(root, "digestionRateMax", traits.digestionRate * TICK_RATE) / TICK_RATE; + traits.digestionTime = json_int(root, "digestionTime"); + traits.blinkChance = json_float(root, "blinkChance"); + traits.gurgleChance = json_float(root, "gurgleChance"); + traits.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier"); character.dialoguePool = json_string(root, "dialoguePool"); object alternate = json_object(root, "alternate"); if (!alternate["texture"].error()) - character.alternateTexture = renderer.texture_get(character.folder + json_string(alternate, "texture")); + character.alternate.texture = renderer.texture_get(character.folder + json_string(alternate, "texture")); if (!alternate["sound"].error()) - character.alternateSound = audio.sound_get(character.folder + json_string(alternate, "sound")); - character.alternateChance = json_float(alternate, "chance"); - character.alternateSheet = json_int(alternate, "spritesheet"); + character.alternate.sound = audio.sound_get(character.folder + json_string(alternate, "sound")); + character.alternate.chance = json_float(alternate, "chance"); + character.alternate.sheet = json_int(alternate, "spritesheet"); object animations = json_object(root, "animations"); - character.animationStart = json_string(animations, "start"); - character.animationIdle = json_string(animations, "idle"); - character.animationIdleFull = json_string(animations, "idleFull"); - character.animationStageUp = json_string(animations, "stageUp"); + character.animations = {json_string(animations, "idle"), json_string(animations, "idleFull"), + json_string(animations, "stageUp"), json_string(animations, "start")}; Anm2* anm2 = character.anm2; auto layer_get = [&](object source, const char* key) { @@ -157,10 +171,9 @@ static void character_parse(object root, Character& character, Renderer& rendere auto null_get = [&](object source, const char* key) { return anm2 ? anm2->null_get(json_string(source, key)) : -1; }; - character.talkSource = layer_get(json_object(root, "talk"), "source"); - character.talkDestination = layer_get(json_object(root, "talk"), "destination"); - character.blinkSource = layer_get(json_object(root, "blink"), "source"); - character.blinkDestination = layer_get(json_object(root, "blink"), "destination"); + character.talk = {layer_get(json_object(root, "talk"), "destination"), layer_get(json_object(root, "talk"), "source")}; + character.blink = {layer_get(json_object(root, "blink"), "destination"), + layer_get(json_object(root, "blink"), "source")}; for (element entry : json_array(root, "eatAreas")) { @@ -225,26 +238,28 @@ static void character_parse(object root, Character& character, Renderer& rendere } object cursor = json_object(root, "cursor"); - character.cursor = character.anm2_get(json_string(cursor, "anm2"), renderer, audio); object cursorAnimations = json_object(cursor, "animations"); - character.cursorIdle = json_string(cursorAnimations, "idle"); - character.cursorClick = json_string(cursorAnimations, "click"); - character.cursorHover = json_string(cursorAnimations, "hover"); - character.cursorGrab = json_string(cursorAnimations, "grab"); - character.cursorPan = json_string(cursorAnimations, "pan"); - character.cursorZoom = json_string(cursorAnimations, "zoom"); - character.cursorDispose = json_string(cursorAnimations, "dispose"); - character.grabParticles = json_strings(json_object(cursor, "particles"), "grab"); + auto cursor_get = [&](const char* key) { + return json_string(cursorAnimations, key); + }; + character.cursor = {cursor_get("click"), + cursor_get("dispose"), + cursor_get("grab"), + cursor_get("hover"), + cursor_get("idle"), + cursor_get("pan"), + cursor_get("zoom"), + json_strings(json_object(cursor, "particles"), "grab"), + character.anm2_get(json_string(cursor, "anm2"), renderer, audio)}; object menu = json_object(root, "menu"); - ImFontAtlas* fonts = ImGui::GetIO().Fonts; - if (!menu["font"].error()) - character.font = fonts->AddFontFromFileTTF((character.folder + json_string(menu, "font")).c_str(), FONT_SIZE); - if (!menu["iconsFont"].error()) - character.iconsFont = - fonts->AddFontFromFileTTF((character.folder + json_string(menu, "iconsFont")).c_str(), FONT_SIZE); - character.rounding = json_float(menu, "rounding"); - character.homeGlyph = json_string(menu, "homeGlyph"); + auto font_get = [&](const char* key) { + std::string path = json_string(menu, key); + return path.empty() ? nullptr + : ImGui::GetIO().Fonts->AddFontFromFileTTF((character.folder + path).c_str(), FONT_SIZE); + }; + character.menu = {json_string(menu, "homeGlyph"), font_get("font"), font_get("iconsFont"), + json_float(menu, "rounding")}; for (auto [name, value] : json_object(root, "particles")) { @@ -253,22 +268,19 @@ static void character_parse(object root, Character& character, Renderer& rendere character.particles[std::string(name)] = { json_string(fields, "animation"), character.anm2_get(json_string(fields, "anm2"), renderer, audio)}; } - - object strings = json_object(root, "strings"); - for (int i = 0; i < STRING_COUNT; i++) - character.strings[i] = json_string(strings, STRING_NAMES[i], STRING_DEFAULTS[i]); } static void items_parse(object root, Character& character, Renderer& renderer, Audio& audio) { + Catalog& catalog = character.catalog; Anm2* baseAnm2 = character.anm2_get(json_string(root, "anm2"), renderer, audio); - character.itemSheet = json_int(root, "spritesheet"); - character.spawnDelay = json_int(root, "spawnDelay", character.spawnDelay); - character.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", character.spawnDelayMultiplier); - character.spawnLimit = json_int(root, "spawnLimit", character.spawnLimit); + catalog.sheet = json_int(root, "spritesheet"); + catalog.spawnDelay = json_int(root, "spawnDelay", catalog.spawnDelay); + catalog.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", catalog.spawnDelayMultiplier); + catalog.spawnLimit = json_int(root, "spawnLimit", catalog.spawnLimit); object particles = json_object(root, "particles"); - character.disposeParticles = json_strings(particles, "dispose"); - character.summonParticles = json_strings(particles, "summon"); + catalog.disposeParticles = json_strings(particles, "dispose"); + catalog.summonParticles = json_strings(particles, "summon"); object categories = json_object(root, "categories"); std::vector rarityNames; @@ -278,7 +290,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A if (value.get(fields)) continue; std::string sound = json_string(fields, "sound"); - character.rarities.push_back({std::string(name), sound.empty() ? nullptr : audio.sound_get(character.folder + sound), + catalog.rarities.push_back({std::string(name), sound.empty() ? nullptr : audio.sound_get(character.folder + sound), json_float(fields, "weight")}); rarityNames.emplace_back(name); } @@ -288,7 +300,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A object fields; if (entry.get(fields)) continue; - Item& item = character.items.emplace_back(); + Item& item = catalog.items.emplace_back(); item.name = json_string(fields, "name", "???"); item.description = json_string(fields, "description", "???"); item.category = json_string(fields, "category"); @@ -323,12 +335,9 @@ static void dialogue_parse(object root, Character& character, Audio& audio) if (!value.get(fields)) dialogue.pauses[std::string(letter)] = {json_int(fields, "delay"), json_is(fields, "isSilent")}; } - for (auto [hook, value] : json_object(root, "hooks")) - { - std::string_view target; - if (!value.get(target)) - dialogue.hooks[std::string(hook)] = target; - } + object hooks = json_object(root, "hooks"); + for (int i = 0; i < HOOK_COUNT; i++) + dialogue.hooks[i] = json_string(hooks, HOOK_NAMES[i]); for (auto [pool, value] : json_object(root, "pools")) { array list; @@ -418,10 +427,11 @@ void Character::load(Renderer& renderer, Audio& audio) // An actor for an item, showing its texture in place of its anm2's item spritesheet. Actor Character::item_actor_get(int item) { - Actor actor(items[item].anm2); + Item& entry = catalog.items[item]; + Actor actor(entry.anm2); actor.isStatic = true; - if (items[item].texture && itemSheet >= 0 && itemSheet < (int)actor.sheets.size()) - actor.sheets[itemSheet] = items[item].texture; + if (entry.texture && catalog.sheet >= 0 && catalog.sheet < (int)actor.sheets.size()) + actor.sheets[catalog.sheet] = entry.texture; return actor; } diff --git a/src/character.h b/src/character.h index 0cd2804..9918e38 100644 --- a/src/character.h +++ b/src/character.h @@ -1,7 +1,6 @@ #pragma once #include "anm2.h" -#include "strings.h" #include #include @@ -38,6 +37,32 @@ enum SoundId SOUND_COUNT }; +// The game events dialogue can hook onto, by their names in dialogue.json. +#define HOOKS(X) \ + X(START, "Start") \ + X(END, "End") \ + X(HELP, "Help") \ + X(RANDOM, "Random") \ + X(STAGE_UP, "StageUp") \ + X(DIGEST, "Digest") \ + X(FEED, "Feed") \ + X(FEED_FULL, "FeedFull") \ + X(EAT, "Eat") \ + X(EAT_FULL, "EatFull") \ + X(FULL, "Full") \ + X(LOW_CAPACITY, "LowCapacity") \ + X(THROW, "Throw") \ + X(FOOD_TAKEN, "FoodTaken") \ + X(FOOD_TAKEN_FULL, "FoodTakenFull") + +enum Hook +{ +#define X(id, name) HOOK_##id, + HOOKS(X) +#undef X + HOOK_COUNT +}; + struct Stage { std::string animationSuffix; @@ -187,13 +212,12 @@ struct Pause bool isSilent = false; }; -// Dialogue entries by label, pools of them picked from at random, and hooks naming the pool (or else entry) the game -// plays on an event. +// Dialogue entries by label, pools of them picked from at random, and the pool (or else entry) each hook plays. struct Dialogue { + std::array hooks; std::unordered_map entries; std::unordered_map pauses; - std::unordered_map hooks; std::unordered_map> pools; Sounds blip; int blipDelay = 0; @@ -201,59 +225,13 @@ struct Dialogue Entry* entry_get(const std::string& label); Entry* pool_get(const std::string& pool); - Entry* hook_get(const std::string& hook); + Entry* hook_get(Hook hook); + bool is_hook(Hook hook); }; -// Everything a character folder holds. The preview (what the select screen shows) loads at startup, the rest when -// played. -class Character +// The body a character starts with and the limits it grows within. Rates are percent per tick. +struct Traits { -public: - std::array sounds; - std::array strings; - std::string animationIdle; - std::string animationIdleFull; - std::string animationStageUp; - std::string animationStart; - std::string anm2Path; - std::string credits; - std::string cursorClick; - std::string cursorDispose; - std::string cursorGrab; - std::string cursorHover; - std::string cursorIdle; - std::string cursorPan; - std::string cursorZoom; - std::string description; - std::string dialoguePool; - std::string folder; - std::string homeGlyph; - std::string key; - std::string name; - std::unordered_map anm2s; - std::unordered_map particles; - std::vector disposeParticles; - std::vector grabParticles; - std::vector summonParticles; - std::vector areas; - std::vector eatAreas; - std::vector expandAreas; - std::vector interactAreas; - std::vector interactTypes; - std::vector items; - std::vector rarities; - std::vector stages; - Dialogue dialogue; - Anm2* anm2 = nullptr; - Anm2* cursor = nullptr; - ImFont* font = nullptr; - ImFont* iconsFont = nullptr; - Sound* alternateSound = nullptr; - Texture* alternateTexture = nullptr; - Texture* portrait = nullptr; - Texture* render = nullptr; - Color color = {0.5f, 0.5f, 0.5f, 1}; - float alternateChance = 0; float blinkChance = 0; float caloriesToKilogram = 0; float capacity = 0; @@ -266,19 +244,104 @@ public: float gurgleChance = 0; float overstuffedDigestBonus = 0; float overstuffedMultiplier = 1; - float rounding = 0; - float spawnDelayMultiplier = 1; float weight = 0; float weightMax = 0; - int alternateSheet = 0; - int blinkDestination = -1; - int blinkSource = -1; int digestionTime = 0; - int itemSheet = 0; +}; + +struct Alternate +{ + Sound* sound = nullptr; + Texture* texture = nullptr; + float chance = 0; + int sheet = 0; +}; + +struct Animations +{ + std::string idle; + std::string idleFull; + std::string stageUp; + std::string start; +}; + +// A layer whose region is shown on another: for talking and blinking. +struct Swap +{ + int destination = -1; + int source = -1; +}; + +struct Cursor +{ + std::string click; + std::string dispose; + std::string grab; + std::string hover; + std::string idle; + std::string pan; + std::string zoom; + std::vector grabParticles; + Anm2* anm2 = nullptr; +}; + +struct Menu +{ + std::string homeGlyph; + ImFont* font = nullptr; + ImFont* iconsFont = nullptr; + float rounding = 0; +}; + +// The items a character can be given, their rarities, and how often they spawn. +struct Catalog +{ + std::vector disposeParticles; + std::vector summonParticles; + std::vector items; + std::vector rarities; + float spawnDelayMultiplier = 1; + int sheet = 0; int spawnDelay = 60; int spawnLimit = 100; - int talkDestination = -1; - int talkSource = -1; + + int item_get(const std::string& name); +}; + +// Everything a character folder holds. The preview (what the select screen shows) loads at startup, the rest when +// played. +class Character +{ +public: + std::array sounds; + std::string anm2Path; + std::string credits; + std::string description; + std::string dialoguePool; + std::string folder; + std::string key; + std::string name; + std::unordered_map anm2s; + std::unordered_map particles; + std::vector areas; + std::vector eatAreas; + std::vector expandAreas; + std::vector interactAreas; + std::vector interactTypes; + std::vector stages; + Alternate alternate; + Animations animations; + Catalog catalog; + Cursor cursor; + Dialogue dialogue; + Menu menu; + Swap blink; + Swap talk; + Traits traits; + Anm2* anm2 = nullptr; + Texture* portrait = nullptr; + Texture* render = nullptr; + Color color = {0.5f, 0.5f, 0.5f, 1}; bool isLoaded = false; bool isValid = false; diff --git a/src/console.cpp b/src/console.cpp index d998979..fcfb72b 100644 --- a/src/console.cpp +++ b/src/console.cpp @@ -5,14 +5,14 @@ constexpr float CONSOLE_HEIGHT = 0.4f; constexpr int INPUT_SIZE = 256; -struct Command +struct ConsoleCommand { const char* name; const char* usage; bool isPlayOnly; }; -constexpr Command COMMANDS[] = { +constexpr ConsoleCommand COMMANDS[] = { {"animations", "animations", true}, {"capacity", "capacity ", true}, {"character", "character ", false}, @@ -66,7 +66,7 @@ void Console::execute(Play* play, std::vector& characters) inputs.push_back(input); inputIndex = -1; history.push_back("> " + input); - auto command = std::find_if(std::begin(COMMANDS), std::end(COMMANDS), [&](const Command& entry) { + auto command = std::find_if(std::begin(COMMANDS), std::end(COMMANDS), [&](const ConsoleCommand& entry) { return name == entry.name; }); if (command == std::end(COMMANDS)) @@ -74,21 +74,18 @@ void Console::execute(Play* play, std::vector& characters) if (command->isPlayOnly && !play) return history.push_back("Command unavailable outside game: " + name); float number = (float)SDL_atof(argument.c_str()); - Progress* progress = play ? &play->progress : nullptr; + Body* body = play ? &play->body : nullptr; if (name == "animations") { - Anm2* anm2 = play->actor.anm2; + Anm2* anm2 = body->actor.anm2; for (int i = 0; anm2 && i < (int)anm2->animations.size(); i++) history.push_back(" " + std::to_string(i) + " " + anm2->animations[i].name); } else if (name == "capacity") - progress->capacity = number; + body->stats.capacity = number; else if (name == "character" || name == "new_game") - { - action = name == "character" ? ACTION_CHARACTER : ACTION_NEW_GAME; - target = argument; - } + commands->push_back({argument, name == "character" ? COMMAND_CONTINUE : COMMAND_NEW_GAME}); else if (name == "characters") { for (Character& character : characters) @@ -101,53 +98,53 @@ void Console::execute(Play* play, std::vector& characters) } else if (name == "digest") { - progress->isDigesting = true; - progress->digestionTimer = 1; + body->stats.isDigesting = true; + body->stats.digestionTimer = 1; } else if (name == "digestion") - progress->digestionRate = number / TICK_RATE; + body->stats.digestionRate = number / TICK_RATE; else if (name == "help") { - for (const Command& entry : COMMANDS) + for (const ConsoleCommand& entry : COMMANDS) if (play || !entry.isPlayOnly) history.push_back(std::string(" ") + entry.usage); } else if (name == "items") { - for (int i = 0; i < (int)play->character->items.size(); i++) - history.push_back(" " + std::to_string(i) + " " + play->character->items[i].name); + std::vector& items = play->character->catalog.items; + for (int i = 0; i < (int)items.size(); i++) + history.push_back(" " + std::to_string(i) + " " + items[i].name); } else if (name == "menu") - action = ACTION_MENU; + commands->push_back({"", COMMAND_MENU}); else if (name == "play_animation") - play->actor.play(argument, TRANSITION_FORCE, INTERRUPT_ALWAYS); + body->actor.play(argument, TRANSITION_FORCE, INTERRUPT_ALWAYS); else if (name == "play_dialogue") play->text.set(*play, play->character->dialogue.entry_get(argument)); else if (name == "reload") - action = ACTION_RELOAD; + commands->push_back({"", COMMAND_RELOAD}); else if (name == "show_layers") - Actor::isShowLayers = is_toggled(argument, Actor::isShowLayers); + renderer->isShowLayers = is_toggled(argument, renderer->isShowLayers); else if (name == "show_nulls") - Actor::isShowNulls = is_toggled(argument, Actor::isShowNulls); + renderer->isShowNulls = is_toggled(argument, renderer->isShowNulls); else if (name == "spawn") { int id = SDL_atoi(argument.c_str()); - if (argument.empty() || id < 0 || id >= (int)play->character->items.size()) + if (argument.empty() || id < 0 || id >= (int)play->character->catalog.items.size()) history.push_back("Unable to spawn item " + argument + "."); else - play->spawn_queue_add(id); + play->items.spawn_queue_add(*play, id); } else if (name == "stage" || name == "weight") { - play->stage = name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : play->stage_get(number); - progress->weight = name == "stage" ? play->stage_threshold_get(play->stage) : number; - play->idle_play(); + body->stage_set(name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : body->stage_get(number)); + body->stats.weight = name == "stage" ? body->stage_threshold_get(body->stage) : number; + body->idle_play(); } } void Console::update(Play* play, std::vector& characters) { - action = ACTION_NONE; if (!isOpen) return; ImVec2 screen = ImGui::GetIO().DisplaySize; diff --git a/src/console.h b/src/console.h index c23d1be..4da0933 100644 --- a/src/console.h +++ b/src/console.h @@ -1,26 +1,18 @@ #pragma once #include "play.h" - -enum ConsoleAction -{ - ACTION_NONE, - ACTION_CHARACTER, - ACTION_MENU, - ACTION_NEW_GAME, - ACTION_RELOAD -}; +#include "ui.h" // A developer console (opened with the grave key when "console=1" is in settings.txt) for inspecting and changing a -// game. Commands that change characters are left as an action for the game to carry out. +// game. Commands that change what's running go to the game's command list. class Console { public: std::vector history; std::vector inputs; std::string input; - std::string target; - ConsoleAction action = ACTION_NONE; + Commands* commands = nullptr; + Renderer* renderer = nullptr; int inputIndex = -1; bool isOpen = false; diff --git a/src/dialogue.cpp b/src/dialogue.cpp index d010981..38012df 100644 --- a/src/dialogue.cpp +++ b/src/dialogue.cpp @@ -1,151 +1,33 @@ #include "dialogue.h" #include "play.h" -#include "ui.h" -#include -#include constexpr float LIFE_MAX = 10; -constexpr float OSCILLATION_AMPLITUDE = 2; -constexpr float OSCILLATION_FREQUENCY = 1; -constexpr float OSCILLATION_SPREAD = 0.16f; constexpr float SCREENSHAKE_MAGNITUDE = 0.03f; constexpr int SCREENSHAKE_TIME = 15; -constexpr float SHAKE_AMPLITUDE = 1; -constexpr float SHAKE_FREQUENCY = 30; -constexpr float TEXT_BIG = 1.25f; -constexpr float TEXT_SMALL = 0.75f; -constexpr float WIDTH_MULTIPLIER = 0.3f; -constexpr float HEIGHT_LINES = 6; -constexpr float INDICATOR_BOB = 0.12f; -constexpr float INDICATOR_SIZE = 0.35f; -constexpr float INDICATOR_SPEED = 2; - -static bool is_effect_on(const Effect& effect, int at) -{ - return effect.start <= at && at < effect.end; -} static bool is_silent(const Entry& entry, int at) { for (const Effect& effect : entry.effects) - if (effect.type == EFFECT_SILENT && is_effect_on(effect, at)) + if (effect.type == EFFECT_SILENT && effect.start <= at && at < effect.end) return true; return false; } -static float hash_unit(Uint32 value) +static bool is_letter_start(const std::string& text, size_t at) { - value ^= value >> 16; - value *= 0x7feb352du; - value ^= value >> 15; - value *= 0x846ca68bu; - value ^= value >> 16; - return (value & 0xffffu) / 65535.0f; + return at >= text.size() || ((unsigned char)text[at] & 0xC0) != 0x80; } -// The scale, color and offset the entry's effects give its character at an index. -static float letter_style_get(const Entry& entry, int at, float time, ImU32& color, ImVec2& offset) +// The UTF-8 character at an index of a text, or empty past its end. +static std::string letter_get(const std::string& text, int at) { - float scale = 1; - color = ImGui::GetColorU32(ImGuiCol_Text); - offset = {}; - for (const Effect& effect : entry.effects) - { - if (!is_effect_on(effect, at)) - continue; - Uint32 seed = (Uint32)std::floor(time * SHAKE_FREQUENCY) * 747796405u + (Uint32)at * 2891336453u; - switch (effect.type) - { - case EFFECT_COLOR: - color = ImGui::GetColorU32(imvec4_get({effect.color.r, effect.color.g, effect.color.b, 1})); - break; - case EFFECT_BIG: - scale = TEXT_BIG; - break; - case EFFECT_SMALL: - scale = TEXT_SMALL; - break; - case EFFECT_OSCILLATION: - offset.y += std::sin((time * OSCILLATION_FREQUENCY + at * OSCILLATION_SPREAD) * 2 * PI) * - OSCILLATION_AMPLITUDE; - break; - case EFFECT_SHAKE: - offset.x += (hash_unit(seed) * 2 - 1) * SHAKE_AMPLITUDE; - offset.y += (hash_unit(seed ^ 0xa511e9b3u) * 2 - 1) * SHAKE_AMPLITUDE; - break; - default: - break; - } - } - return scale; -} - -// Draws the entry's first count characters with their effects, wrapping whole words to the window. -static void letters_draw(const Entry& entry, int count, float time) -{ - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - ImVec2 start = ImGui::GetCursorScreenPos(); - float wrap = start.x + ImGui::GetContentRegionAvail().x; - ImVec2 cursor = start; - float lineHeight = fontSize; - const char* end = entry.text.data() + entry.text.size(); - auto width_get = [&](const char* from, const char* to, int at) { - ImU32 color; - ImVec2 offset; - return font->CalcTextSizeA(fontSize * letter_style_get(entry, at, time, color, offset), FLT_MAX, 0, from, to).x; - }; - auto line_break = [&]() { - cursor = {start.x, cursor.y + lineHeight}; - lineHeight = fontSize; - }; - - int at = 0; - for (const char* it = entry.text.data(); it < end && at < count; at++) - { - unsigned int codepoint = 0; - const char* next = it + ImTextCharFromUtf8(&codepoint, it, end); - if (codepoint == '\n') - { - line_break(); - it = next; - continue; - } - bool isSpace = codepoint == ' '; - if (!isSpace && cursor.x > start.x && it[-1] == ' ') - { - float wordWidth = 0; - int wordAt = at; - for (const char* letter = it; letter < end && *letter != ' ' && *letter != '\n'; wordAt++) - { - unsigned int wordCodepoint = 0; - const char* letterNext = letter + ImTextCharFromUtf8(&wordCodepoint, letter, end); - wordWidth += width_get(letter, letterNext, wordAt); - letter = letterNext; - } - if (cursor.x + wordWidth > wrap && wordWidth <= wrap - start.x) - line_break(); - } - ImU32 color; - ImVec2 offset; - float scale = letter_style_get(entry, at, time, color, offset); - float width = width_get(it, next, at); - if (cursor.x + width > wrap) - { - line_break(); - if (isSpace) - { - it = next; - continue; - } - } - ImGui::GetWindowDrawList()->AddText(font, fontSize * scale, {cursor.x + offset.x, cursor.y + offset.y}, color, - it, next); - cursor.x += width; - lineHeight = std::max(lineHeight, fontSize * scale); - it = next; - } - ImGui::Dummy({wrap - start.x, cursor.y + lineHeight - start.y}); + size_t start = 0; + while (start < text.size() && at > 0) + at -= is_letter_start(text, ++start); + size_t end = std::min(start + 1, text.size()); + while (!is_letter_start(text, end)) + end++; + return text.substr(start, end - start); } void Text::set(Play& play, Entry* next, TextMode mode, const std::string& onEnd) @@ -169,54 +51,50 @@ void Text::set(Play& play, Entry* next, TextMode mode, const std::string& onEnd) Interrupt interrupt = mode == TEXT_SEQUENCE ? INTERRUPT_ALWAYS : mode == TEXT_INTERRUPTIBLE ? INTERRUPT_IF_ALLOWED : INTERRUPT_NEVER; - if (!next->animation.empty()) - play.body_play(next->animation, TRANSITION_NORMAL, interrupt); - delayBase = play.character->dialogue.delay; - delay = delayBase; + play.body.play(next->animation, TRANSITION_NORMAL, interrupt); + delay = delayBase = play.character->dialogue.delay; for (Effect& effect : next->effects) { if (effect.start != 0) continue; if (effect.type == EFFECT_DELAY) delay = delayBase = std::max(1, effect.delay); - if (effect.type == EFFECT_ANIMATION && !effect.animation.empty()) - play.body_play(effect.animation, TRANSITION_FORCE); + if (effect.type == EFFECT_ANIMATION) + play.body.play(effect.animation, TRANSITION_FORCE); } if (!next->text.empty()) - play.isTalking = !is_silent(*next, 0); -} - -// Applies an effect as its character comes up: a delay or animation, a screen shake or a sound. -static void effect_apply(Play& play, Text& text, const Effect& effect) -{ - if (effect.type == EFFECT_DELAY) - text.delay = text.delayBase = std::max(1, effect.delay); - else if (effect.type == EFFECT_ANIMATION && !effect.animation.empty()) - play.body_play(effect.animation, TRANSITION_FORCE); - else if (effect.type == EFFECT_SCREENSHAKE) - play.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME); - else if (effect.type == EFFECT_SOUND) - play.audio->play(effect.sound); + play.body.isTalking = !is_silent(*next, 0); } +// Applies the effects coming up at a character: delays and animations, screen shakes and sounds. void Text::effects_apply(Play& play, int at) { for (Effect& effect : entry->effects) - if (effect.start == at) - effect_apply(play, *this, effect); + { + if (effect.start != at) + continue; + if (effect.type == EFFECT_DELAY) + delay = delayBase = std::max(1, effect.delay); + else if (effect.type == EFFECT_ANIMATION) + play.body.play(effect.animation, TRANSITION_FORCE); + else if (effect.type == EFFECT_SCREENSHAKE) + play.camera.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME); + else if (effect.type == EFFECT_SOUND) + play.audio->play(effect.sound); + } } void Text::clear(Play& play) { if (!idleOnEnd.empty()) { - play.idle_override_set(idleOnEnd); - play.idle_play(); + play.body.idle_override_set(idleOnEnd); + play.body.idle_play(); idleOnEnd.clear(); } entry = nullptr; isScene = false; - play.isTalking = false; + play.body.isTalking = false; } bool Text::is_interruptible() @@ -224,157 +102,98 @@ bool Text::is_interruptible() return !entry || entry->isTerminal; } -void Text::update(Play& play, Vec2 screenSize) +// The player's click or key: shows the rest of the line, or else moves to the next entry or closes the text. +void Text::advance(Play& play) +{ + if (!entry) + return; + if (!isFinished) + { + for (int skipped = index + 1; skipped <= entry->length; skipped++) + effects_apply(play, skipped); + index = entry->length; + isFinished = true; + play.body.isTalking = false; + isScene &= !entry->isTerminal; + } + else if (!entry->choices.empty() || !entry->next.empty()) + { + play.audio->play(play.character->sounds[SOUND_SELECT]); + if (!entry->choices.empty()) + choose(play, 0); + else + set(play, play.character->dialogue.entry_get(entry->next), TEXT_SEQUENCE); + } + else + clear(play); +} + +void Text::choose(Play& play, int choice) +{ + if (entry && choice >= 0 && choice < (int)entry->choices.size()) + set(play, play.character->dialogue.entry_get(entry->choices[choice].next), TEXT_SEQUENCE); +} + +// Reveals the next character when its delay is up, with its effects, blips and talking. Entries without text wait out +// their animation, and finished last entries close after a while. +void Text::update(Play& play) { if (!entry) return; Dialogue& dialogue = play.character->dialogue; - if (!isFinished) + if (!isStarted) { - if (!isStarted) + isStarted = true; + for (Effect& effect : entry->effects) + if (effect.start == 0 && effect.type == EFFECT_SCREENSHAKE) + play.camera.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME); + else if (effect.start == 0 && effect.type == EFFECT_SOUND) + play.audio->play(effect.sound); + } + if (!isFinished && ++tick >= delay) + { + tick = 0; + index++; + delay = delayBase; + effects_apply(play, index); + auto pause = dialogue.pauses.find(letter_get(entry->text, index - 1)); + Pause found = pause == dialogue.pauses.end() ? Pause{} : pause->second; + bool isSilentEffect = is_silent(*entry, index - 1); + delay += found.delay; + play.body.isTalking = !found.isSilent && !isSilentEffect; + bool isBlipStep = dialogue.blipDelay > 0 && index % dialogue.blipDelay == 0; + if (isSilentEffect) + isBlipQueued = false; + else if (found.isSilent) + isBlipQueued |= isBlipStep; + else if (isBlipStep || isBlipQueued) { - isStarted = true; - for (Effect& effect : entry->effects) - if (effect.start == 0 && (effect.type == EFFECT_SCREENSHAKE || effect.type == EFFECT_SOUND)) - effect_apply(play, *this, effect); + play.audio->play(dialogue.blip); + isBlipQueued = false; } - if (++tick >= delay) + if (index >= entry->length) { - tick = 0; - index++; - delay = delayBase; - effects_apply(play, index); - const char* letter = entry->text.c_str(); - const char* end = letter + entry->text.size(); - unsigned int codepoint = 0; - for (int i = 0; i < index - 1 && letter < end; i++) - letter += ImTextCharFromUtf8(&codepoint, letter, end); - int letterSize = letter < end ? ImTextCharFromUtf8(&codepoint, letter, end) : 0; - auto pause = dialogue.pauses.find(std::string(letter, letterSize)); - Pause found = pause == dialogue.pauses.end() ? Pause{} : pause->second; - bool isSilentEffect = is_silent(*entry, index - 1); - delay += found.delay; - play.isTalking = !found.isSilent && !isSilentEffect; - bool isBlipStep = dialogue.blipDelay > 0 && index % dialogue.blipDelay == 0; - if (isSilentEffect) - isBlipQueued = false; - else if (found.isSilent) - isBlipQueued |= isBlipStep; - else if (isBlipStep || isBlipQueued) - { - play.audio->play(dialogue.blip); - isBlipQueued = false; - } - if (index >= entry->length) - { - isFinished = true; - isBlipQueued = false; - play.isTalking = false; - isScene &= !entry->isTerminal; - } + isFinished = true; + isBlipQueued = false; + play.body.isTalking = false; + isScene &= !entry->isTerminal; } } if (entry->text.empty()) { - Anm2Animation* animation = play.actor.animation_get(); - bool isReady = entry->animation.empty() || !animation || !play.actor.isPlaying || + Actor& actor = play.body.actor; + Anm2Animation* animation = actor.animation_get(); + bool isReady = entry->animation.empty() || !animation || !actor.isPlaying || (!isAnimationStarted && animation->length <= 1) || - (animation->isLoop && isAnimationStarted && play.actor.time < animationTimePrevious); + (animation->isLoop && isAnimationStarted && actor.time < animationTimePrevious); isAnimationStarted = true; - animationTimePrevious = play.actor.time; + animationTimePrevious = actor.time; if (entry->next.empty()) clear(play); else if (isReady) set(play, dialogue.entry_get(entry->next), TEXT_SEQUENCE); - return; } - - ImGuiStyle& style = ImGui::GetStyle(); - ImVec2 size = {screenSize.x * WIDTH_MULTIPLIER - style.WindowPadding.x * 2, - ImGui::GetTextLineHeightWithSpacing() * HEIGHT_LINES}; - ImGui::SetNextWindowSize(size); - ImGui::SetNextWindowPos({screenSize.x / 2 - size.x / 2, screenSize.y - size.y - style.WindowPadding.y}); - if (ImGui::Begin("##Text", nullptr, - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove)) - { - bool isHovered = ImGui::IsWindowHovered(); - bool isSpace = ImGui::IsKeyReleased(ImGuiKey_Space); - bool isAdvance = (isHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) || isSpace; - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {style.ItemSpacing.x, 0}); - if (ImGui::BeginTabBar("##Name")) - { - if (ImGui::BeginTabItem(play.character->name.c_str())) - ImGui::EndTabItem(); - ImGui::EndTabBar(); - } - ImGui::PushFont(nullptr, FONT_NORMAL); - letters_draw(*entry, index, (float)ImGui::GetTime()); - - ImVec2 windowPosition = ImGui::GetWindowPos(); - ImVec2 contentMin = {windowPosition.x + style.WindowPadding.x, windowPosition.y + style.WindowPadding.y}; - ImVec2 contentMax = {windowPosition.x + ImGui::GetWindowWidth() - style.WindowPadding.x, - windowPosition.y + ImGui::GetWindowHeight() - style.WindowPadding.y}; - Entry* current = entry; - if (!isFinished && isAdvance) - { - for (int skipped = index + 1; skipped <= entry->length; skipped++) - effects_apply(play, skipped); - index = entry->length; - isFinished = true; - play.isTalking = false; - isScene &= !entry->isTerminal; - } - else if (isFinished && !current->choices.empty()) - { - float buttonY = contentMax.y - ImGui::GetFrameHeight(); - float separatorY = buttonY - style.ItemSpacing.y; - ImGui::GetWindowDrawList()->AddLine({contentMin.x, separatorY}, {contentMax.x, separatorY}, - ImGui::GetColorU32(ImGuiCol_Separator)); - ImGui::SetCursorScreenPos({contentMin.x, buttonY}); - ImVec2 buttonSize = {row_width_get((int)current->choices.size()), 0}; - for (int i = 0; i < (int)current->choices.size(); i++) - { - Choice& choice = current->choices[i]; - ImGui::PushID(i); - if (is_fx(ImGui::Button(choice.text.c_str(), buttonSize))) - set(play, dialogue.entry_get(choice.next), TEXT_SEQUENCE); - ImGui::SetItemTooltip("%s", choice.text.c_str()); - ImGui::PopID(); - if (i + 1 < (int)current->choices.size()) - ImGui::SameLine(); - } - if (isHovered && isSpace) - { - play.audio->play(play.character->sounds[SOUND_SELECT]); - set(play, dialogue.entry_get(current->choices.front().next), TEXT_SEQUENCE); - } - } - else if (isFinished && !current->next.empty()) - { - float side = ImGui::GetTextLineHeightWithSpacing(); - ImVec2 center = {contentMax.x - side / 2 + - std::sin((float)ImGui::GetTime() * INDICATOR_SPEED) * side * INDICATOR_BOB, - contentMax.y - side / 2}; - float half = side * INDICATOR_SIZE; - ImGui::GetWindowDrawList()->AddTriangleFilled({center.x + half, center.y}, - {center.x - half, center.y - half}, - {center.x - half, center.y + half}, - ImGui::GetColorU32(ImGuiCol_Text)); - if (isAdvance) - { - play.audio->play(play.character->sounds[SOUND_SELECT]); - set(play, dialogue.entry_get(current->next), TEXT_SEQUENCE); - } - } - else if (isFinished && isAdvance) - clear(play); - ImGui::PopFont(); - ImGui::PopStyleVar(); - } - ImGui::End(); - - if (entry && isFinished && entry->isTerminal && (life += ImGui::GetIO().DeltaTime) > LIFE_MAX) + else if (isFinished && entry->isTerminal && (life += TICK) > LIFE_MAX) clear(play); } diff --git a/src/dialogue.h b/src/dialogue.h index ac0334d..9666114 100644 --- a/src/dialogue.h +++ b/src/dialogue.h @@ -12,7 +12,7 @@ enum TextMode }; // Plays out a dialogue entry a character at a time with its effects, then offers its choices or the entry it leads to. -// A scene holds the game still until its last entry finishes. +// A scene holds the game still until its last entry finishes. The UI shows it and passes on the player's input. class Text { public: @@ -31,7 +31,9 @@ public: bool isStarted = false; void set(Play& play, Entry* next, TextMode mode = TEXT_INTERRUPTIBLE, const std::string& onEnd = ""); - void update(Play& play, Vec2 screenSize); + void update(Play& play); + void advance(Play& play); + void choose(Play& play, int choice); void clear(Play& play); bool is_interruptible(); diff --git a/src/interact.cpp b/src/interact.cpp new file mode 100644 index 0000000..41e4cc3 --- /dev/null +++ b/src/interact.cpp @@ -0,0 +1,289 @@ +#include "play.h" + +constexpr float AUTO_ACCELERATION = 0.45f; +constexpr float AUTO_ANGLE_STEP = 0.08f; +constexpr float AUTO_BRAKING = 2; +constexpr float AUTO_CALORIE_EPSILON = 0.001f; +constexpr float AUTO_DIGEST_ACCELERATION = 0.15f; +constexpr float AUTO_DIGEST_RADIUS = 0.25f; +constexpr float AUTO_DIGEST_SPEED = 3; +constexpr float AUTO_SCALE_AREA = 512.0f * 512.0f; +constexpr float AUTO_SCALE_MAX = 2; +constexpr float AUTO_SCALE_MIN = 0.5f; +constexpr float AUTO_SPEED = 9; +constexpr float AUTO_TARGET_EPSILON = 0.001f; +constexpr int AUTO_CLICK_COOLDOWN = 45; + +void Interactions::update(Play& play, Pointer pointer) +{ + Character& character = *play.character; + Body& body = play.body; + Text& text = play.text; + Actor& active = play.pointer_actor_get(); + if (text.isScene) + { + isInteracting = isHolding = isHovering = false; + area = -1; + return; + } + bool wasInteracting = isInteracting; + bool wasHolding = isHolding; + bool wasHovering = isHovering; + isHovering = false; + isHolding &= isInteracting; + cooldowns.resize(character.interactAreas.size()); + for (int& cooldown : cooldowns) + cooldown = text.entry ? std::max(0, cooldown - 1) : 0; + + for (int i = 0; i < (int)character.interactAreas.size(); i++) + { + InteractArea& entry = character.interactAreas[i]; + bool isInside = is_point_in_rect(body.actor.null_rect_get(entry.null), pointer.position); + if (play.cursorState == CURSOR_DEFAULT && isInside && !pointer.isCaptured && entry.type == pointer.type) + { + play.cursorState = CURSOR_HOVER; + active.play(entry.cursorHover, TRANSITION_IF_IDLE); + isHovering = true; + area = i; + bool isHoldStart = entry.isHold && (pointer.isClicked || (pointer.isDown && !isInteracting)); + if (isHoldStart || (!entry.isHold && pointer.isClicked)) + { + if (entry.isHold) + isInteracting = isHolding = true; + else + { + play.cursorState = CURSOR_ACTION; + active.play(entry.cursorActive, TRANSITION_FORCE, INTERRUPT_ALWAYS); + } + play.audio->play(entry.sounds); + body.idle_override_set(entry.idleOnEnd); + play.particles_spawn(entry.particles, pointer.position); + if (entry.digestionClick > 0 && body.stats.calories > 0 && !body.stats.isDigesting) + body.stats.digestionProgress += entry.digestionClick; + body.actor.pulse_add(entry.layer, entry.scaleAmplitude, entry.scaleCycles, entry.scaleTime); + bool isReady = cooldowns[i] <= 0; + cooldowns[i] = std::max(0, entry.dialogueCooldown); + Dialogue& dialogue = character.dialogue; + bool isFull = body.is_over_capacity() && dialogue.pools.contains(entry.dialoguePoolFull); + const std::string& pool = isFull ? entry.dialoguePoolFull : entry.dialoguePool; + if (dialogue.pools.contains(pool)) + { + if (text.is_interruptible() && isReady) + text.set(play, dialogue.pool_get(pool), TEXT_INTERRUPTIBLE, entry.isHold ? "" : entry.idleOnEnd); + } + else if (entry.dialoguePool.empty() && !entry.animation.empty()) + body.play(entry.animation, TRANSITION_FORCE); + } + if (isInteracting) + { + play.cursorState = CURSOR_ACTION; + active.play(entry.cursorActive); + Vec2 moved = pointer.position - pointerPrevious; + if (entry.digestionHover > 0 && body.stats.calories > 0 && !body.stats.isDigesting && + (moved.x != 0 || moved.y != 0)) + body.stats.digestionProgress += entry.digestionHover; + } + } + if ((i == area && !isInside) || pointer.isReleased || pointer.isCaptured) + { + isInteracting = isHolding = false; + area = -1; + } + } + + if ((wasInteracting && !isInteracting) || (wasHovering && !isHovering)) + active.play(character.cursor.idle); + if (wasHolding && !isHolding && !body.actor.pending) + body.idle_play(); + pointerPrevious = pointer.position; +} + +// Moves the cursor toward a target, braking to land on it. Returns whether it's there. +static bool is_target_reached(Vec2& position, Vec2& velocity, Vec2 target, float speed, float acceleration) +{ + Vec2 delta = target - position; + float distance = vec2_length(delta); + if (!std::isfinite(distance)) + return false; + if (distance <= AUTO_TARGET_EPSILON) + { + position = target; + velocity = {}; + return true; + } + Vec2 desired = delta / distance * std::min(speed, std::sqrt(AUTO_BRAKING * acceleration * distance)); + Vec2 change = desired - velocity; + float changeLength = vec2_length(change); + velocity = changeLength > acceleration ? velocity + change / changeLength * acceleration : desired; + if (vec2_length(velocity) >= distance) + { + position = target; + velocity = {}; + return true; + } + position = position + velocity; + return false; +} + +static bool is_autofeed_food(const Item& item) +{ + return item.use == USE_EDIBLE && item.capacityBonus >= 0 && item.digestionBonus >= 0; +} + +static bool is_edible(Play& play, const Item& item) +{ + return is_autofeed_food(item) && item.calories > 0 && + play.body.stats.calories + item.calories <= play.body.capacity_max_get(); +} + +// The edible item in the world with the most calories, ties broken at random. +static int food_best_get(Play& play) +{ + std::vector& catalog = play.character->catalog.items; + std::vector& list = play.items.list; + int best = -1; + int ties = 0; + for (int i = 0; i < (int)list.size(); i++) + { + Item& item = catalog[list[i].id]; + if (!is_edible(play, item)) + continue; + float bestCalories = best >= 0 ? catalog[list[best].id].calories : -1; + if (item.calories > bestCalories) + best = i, ties = 1; + else if (item.calories == bestCalories && SDL_rand(++ties) == 0) + best = i; + } + return best; +} + +// The first interact area that helps digestion, which the autofeed rubs or pats. +static InteractArea* digest_area_get(Play& play) +{ + for (InteractArea& area : play.character->interactAreas) + if (area.digestionClick > 0 || area.digestionHover > 0) + return &area; + return nullptr; +} + +int Autofeed::type_get(Play& play) +{ + InteractArea* area = digest_area_get(play); + return area ? area->type : play.cursorType; +} + +bool Autofeed::is_available(Play& play) +{ + bool isFood = play.body.stats.calories > 0 || play.items.is_spawn_possible(play); + for (ItemEntity& entity : play.items.list) + isFood |= play.character->catalog.items[entity.id].use == USE_EDIBLE; + return isFood; +} + +void Autofeed::reset() +{ + isItemHeld = isDigestHeld = false; + target = -1; + clickCooldown = 0; + velocity = {}; +} + +void Autofeed::update(Play& play, Pointer& itemPointer, Pointer& interactPointer) +{ + Body& body = play.body; + Items& items = play.items; + Actor& cursor = play.autoCursor; + std::vector& catalog = play.character->catalog.items; + cursor.isVisible = false; + bool isWorldFood = false; + bool isAnyEdible = false; + for (ItemEntity& entity : items.list) + { + isWorldFood |= is_autofeed_food(catalog[entity.id]); + isAnyEdible |= is_edible(play, catalog[entity.id]); + } + if (!isEnabled || body.isStageUp) + return reset(); + items.spawn_queue_add(play); + if (body.stats.calories <= AUTO_CALORIE_EPSILON && !isWorldFood) + return reset(); + + if (cursor.position.x == 0 && cursor.position.y == 0) + cursor.position = body.actor.position; + Rect bodyBounds = body.actor.bounds_get(); + float speedScale = is_rect_valid(bodyBounds) ? std::clamp(std::sqrt(bodyBounds.w * bodyBounds.h / AUTO_SCALE_AREA), + AUTO_SCALE_MIN, AUTO_SCALE_MAX) + : 1; + Rect mouth = {NAN, NAN, NAN, NAN}; + for (EatArea& eatArea : play.character->eatAreas) + if (!is_rect_valid(mouth)) + mouth = body.eat_rect_get(eatArea); + bool isMouth = is_rect_valid(mouth); + auto move = [&](Vec2 destination, float speed, float acceleration) { + isActive = cursor.isVisible = true; + itemPointer = interactPointer = {.position = cursor.position, .isAutomated = true}; + bool isThere = + is_target_reached(cursor.position, velocity, destination, speed * speedScale, acceleration * speedScale); + itemPointer.position = interactPointer.position = cursor.position; + return isThere; + }; + + if (items.held >= 0 && items.held < (int)items.list.size()) + { + bool isCarried = isMouth && is_edible(play, catalog[items.list[items.held].id]); + move(isCarried ? rect_middle(mouth) : cursor.position, AUTO_SPEED, AUTO_ACCELERATION); + itemPointer.isDown = isItemHeld = isCarried; + itemPointer.isReleased = !isCarried; + isDigestHeld = false; + target = isCarried ? target : -1; + return; + } + + bool isTargetValid = target >= 0 && target < (int)items.list.size() && is_edible(play, catalog[items.list[target].id]); + if (!isMouth || !isTargetValid) + target = isMouth ? food_best_get(play) : -1; + if (target >= 0) + { + Rect rect = items.list[target].actor.bounds_get(); + if (!is_rect_valid(rect)) + return reset(); + move(rect_middle(rect), AUTO_SPEED, AUTO_ACCELERATION); + bool isInside = is_point_in_rect(rect, cursor.position); + itemPointer.target = target; + itemPointer.isClicked = isInside && !isItemHeld; + itemPointer.isDown = isInside || isItemHeld; + isItemHeld |= isInside; + isDigestHeld = false; + return; + } + if (!isWorldFood && isAnyEdible) + return reset(); + + InteractArea* area = digest_area_get(play); + Rect rect = area ? body.actor.null_rect_get(area->null) : Rect{NAN, NAN, NAN, NAN}; + if (!is_rect_valid(rect)) + return reset(); + angle += AUTO_ANGLE_STEP; + float radius = std::max(1.0f, std::min(rect.w, rect.h) * AUTO_DIGEST_RADIUS); + Vec2 destination = rect_middle(rect) + Vec2{std::cos(angle), std::sin(angle)} * radius; + bool isThere = move(destination, AUTO_DIGEST_SPEED, AUTO_DIGEST_ACCELERATION); + interactPointer.type = area->type; + if (area->digestionHover > 0) + { + bool isInside = is_point_in_rect(rect, cursor.position); + interactPointer.isClicked = isInside && !isDigestHeld; + interactPointer.isDown = isInside; + interactPointer.isReleased = isDigestHeld && !isInside; + isDigestHeld = isInside; + } + else + { + clickCooldown = std::max(0, clickCooldown - 1); + interactPointer.isClicked = isThere && clickCooldown <= 0 && !isDigestHeld; + interactPointer.isReleased = isDigestHeld; + if (interactPointer.isClicked) + clickCooldown = AUTO_CLICK_COOLDOWN; + isDigestHeld = interactPointer.isClicked; + } + isItemHeld = false; +} diff --git a/src/items.cpp b/src/items.cpp index 145b146..0f14605 100644 --- a/src/items.cpp +++ b/src/items.cpp @@ -1,139 +1,123 @@ #include "play.h" -#include -constexpr float AUTO_ACCELERATION = 0.45f; -constexpr float AUTO_ANGLE_STEP = 0.08f; -constexpr float AUTO_BRAKING = 2; -constexpr float AUTO_CALORIE_EPSILON = 0.001f; -constexpr float AUTO_DIGEST_ACCELERATION = 0.15f; -constexpr float AUTO_DIGEST_RADIUS = 0.25f; -constexpr float AUTO_DIGEST_SPEED = 3; -constexpr float AUTO_SCALE_MAX = 2; -constexpr float AUTO_SCALE_MIN = 0.5f; -constexpr float AUTO_SCALE_AREA = 512.0f * 512.0f; -constexpr float AUTO_SPEED = 9; -constexpr float AUTO_TARGET_EPSILON = 0.001f; -constexpr int AUTO_CLICK_COOLDOWN = 45; constexpr float HOLD_SPIN = 0.1f; constexpr float ROTATION_GROUND_DAMPING = 0.85f; constexpr float ROTATION_MAX = 90; constexpr float ROTATION_RETURN_SPEED = 120; constexpr float THROW_THRESHOLD = 10; -void Play::item_add(int id, Vec2 position, Vec2 velocity, float rotation) +void Items::add(Play& play, int id, Vec2 position, Vec2 velocity, float rotation) { - ItemEntity& entity = items.emplace_back(ItemEntity{character->item_actor_get(id), velocity, 0, id}); + ItemEntity& entity = list.emplace_back(ItemEntity{play.character->item_actor_get(id), velocity, 0, id}); entity.actor.position = position; - entity.actor.overrides.push_back({.rotation = rotation, .target = TARGET_ROOT, .isRotation = true}); + entity.actor.rotation = rotation; discovered[id] = true; } -int Play::spawn_delay_get() +// Forgets which item is being eaten, and lets go of the held one unless asked to keep it. +void Items::drop(Hold hold) { - return std::max(1, (int)std::round(character->spawnDelay * std::pow(character->spawnDelayMultiplier, stage))); + if (hold == HOLD_RELEASE) + { + held = -1; + feeding.animation = -1; + } + feeding.item = -1; + feeding.event = -1; + feeding.rect = {NAN, NAN, NAN, NAN}; +} + +int Items::spawn_delay_get(Play& play) +{ + Catalog& catalog = play.character->catalog; + return std::max(1, (int)std::round(catalog.spawnDelay * std::pow(catalog.spawnDelayMultiplier, play.body.stage))); } static float spawn_weight_get(Play& play, int id) { - Item& item = play.character->items[id]; - if (item.calories > play.capacity_max_get() || item.rarity < 0) + Catalog& catalog = play.character->catalog; + Item& item = catalog.items[id]; + if (item.calories > play.body.capacity_max_get() || item.rarity < 0) return 0; - return std::max(0.0f, play.character->rarities[item.rarity].weight); + return std::max(0.0f, catalog.rarities[item.rarity].weight); } -bool Play::is_spawn_possible() +bool Items::is_spawn_possible(Play& play) { - if ((int)items.size() >= character->spawnLimit) + Catalog& catalog = play.character->catalog; + if ((int)list.size() >= catalog.spawnLimit) return false; - for (int i = 0; i < (int)character->items.size(); i++) - if (spawn_weight_get(*this, i) > 0) + for (int i = 0; i < (int)catalog.items.size(); i++) + if (spawn_weight_get(play, i) > 0) return true; return false; } -void Play::spawn_queue_add(int id) +void Items::spawn_queue_add(Play& play, int id) { - if (id < 0 || id >= (int)character->items.size()) + Catalog& catalog = play.character->catalog; + if (id < 0 || id >= (int)catalog.items.size()) return; - Item& item = character->items[id]; - audio->play(character->sounds[SOUND_SUMMON]); - if (item.rarity >= 0) - audio->play(character->rarities[item.rarity].sound); + play.audio->play(play.character->sounds[SOUND_SUMMON]); + if (catalog.items[id].rarity >= 0) + play.audio->play(catalog.rarities[catalog.items[id].rarity].sound); spawnQueue.push_back(id); - spawnDelay = spawn_delay_get(); + spawnDelay = spawn_delay_get(play); } // Queues an item picked at random by rarity, once the spawn delay is up. -void Play::spawn_queue_add() +void Items::spawn_queue_add(Play& play) { - if (spawnDelay > 0 || !is_spawn_possible()) + if (spawnDelay > 0 || !is_spawn_possible(play)) return; + std::vector& items = play.character->catalog.items; float total = 0; - for (int i = 0; i < (int)character->items.size(); i++) - total += spawn_weight_get(*this, i); + for (int i = 0; i < (int)items.size(); i++) + total += spawn_weight_get(play, i); float roll = random_get(0, total); - for (int i = 0; i < (int)character->items.size(); i++) - if (float weight = spawn_weight_get(*this, i); weight > 0 && (roll -= weight) < 0) - return spawn_queue_add(i); + for (int i = 0; i < (int)items.size(); i++) + if (float weight = spawn_weight_get(play, i); weight > 0 && (roll -= weight) < 0) + return spawn_queue_add(play, i); } -// Where an eat area takes food: its null now, or at the start of its animation when it isn't showing. -Rect Play::eat_rect_get(const EatArea& eatArea) +void Items::update(Play& play, Pointer pointer) { - Rect rect = actor.null_rect_get(eatArea.null); - if (!is_rect_valid(rect)) - rect = actor.null_rect_get(eatArea.null, actor.anm2->animation_get(eatArea.animation + suffix_get()), 0); - return rect; -} - -// Forgets which item is being eaten, and lets go of the held one when asked. -void Play::item_drop(Hold hold) -{ - heldItem = hold == HOLD_RELEASE ? -1 : heldItem; - eatAnimation = hold == HOLD_RELEASE ? -1 : eatAnimation; - eatItem = -1; - eatEvent = -1; - eatRect = {NAN, NAN, NAN, NAN}; -} - -static Actor& pointer_actor_get(Play& play) -{ - return play.isAutofeedActive ? play.autoCursor : play.cursor; -} - -void Play::items_update(Pointer pointer) -{ - Character& data = *character; - Actor& active = pointer_actor_get(*this); - int areaIndex = data.area_get(stage); - Area area = areaIndex >= 0 ? data.areas[areaIndex] : Area{}; - bool isOver = is_over_capacity(); - auto text_play = [&](const char* hook) { + Character& character = *play.character; + Body& body = play.body; + Stats& stats = body.stats; + Actor& active = play.pointer_actor_get(); + Audio& audio = *play.audio; + int areaIndex = character.area_get(body.stage); + Area area = areaIndex >= 0 ? character.areas[areaIndex] : Area{}; + Rect bounds = play.bounds; + bool isOver = body.is_over_capacity(); + auto text_play = [&](Hook hook) { if (!pointer.isAutomated) - text.set(*this, data.dialogue.hook_get(hook)); + play.text.set(play, character.dialogue.hook_get(hook)); }; - auto held_release = [&](bool isThrown) { - item_drop(); - active.play(data.cursorIdle); - if (!isThrown && !actor.pending) - idle_play(); + auto release = [&](bool isThrown) { + drop(); + active.play(character.cursor.idle); + if (!isThrown && !body.actor.pending) + body.idle_play(); }; - if (text.isScene && heldItem >= 0) + if (play.text.isScene && held >= 0) { - held_release(false); - isEatTextPending = false; + release(false); + feeding.isTextPending = false; } - if (isEatTextPending && eatAnimation == actor.animation && (!actor.isPlaying || actor.is_finished())) + if (feeding.isTextPending && feeding.animation == body.actor.animation && + (!body.actor.isPlaying || body.actor.is_finished())) { - text.set(*this, data.dialogue.hook_get(isEatTextFull ? "EatFull" : "Eat")); - isEatTextPending = false; - eatAnimation = -1; + play.text.set(play, character.dialogue.hook_get(feeding.isTextFull ? HOOK_EAT_FULL : HOOK_EAT)); + feeding.isTextPending = false; + feeding.animation = -1; } - bool wasItemHovered = isItemHovered; - isItemHovered = false; - tooltipItem = -1; + bool wasHovered = isHovered; + isHovered = false; + tooltip = -1; if (spawnDelay > 0) spawnDelay--; for (int id : spawnQueue) @@ -142,149 +126,151 @@ void Play::items_update(Pointer pointer) Vec2 position = {spawn.x + random_get(0, spawn.w), spawn.y + random_get(0, spawn.h)}; if (!discovered[id]) { - toast_add(STRING_ToastNewItemFound); - audio->play(data.sounds[SOUND_NEW_ITEM]); + play.toast_add("New item found!"); + audio.play(character.sounds[SOUND_NEW_ITEM]); } - item_add(id, position); - progress.totalSpawned++; - particles_spawn(data.summonParticles, position); + add(play, id, position); + stats.totalSpawned++; + play.particles_spawn(character.catalog.summonParticles, position); } spawnQueue.clear(); - if (heldItem >= 0 && heldItem < (int)items.size()) + if (held >= 0 && held < (int)list.size()) { - ItemEntity& held = items[heldItem]; - Item& item = data.items[held.id]; - float& rotation = held.rotation_get(); - Vec2 delta = itemPointerPrevious - pointer.position; - held.actor.position = pointer.position; - held.velocity = {}; - held.angularVelocity = (held.angularVelocity - delta.x * HOLD_SPIN) * area.friction; + ItemEntity& entity = list[held]; + Item& item = character.catalog.items[entity.id]; + float& rotation = *entity.actor.rotation; + Vec2 delta = pointerPrevious - pointer.position; + entity.actor.position = pointer.position; + entity.velocity = {}; + entity.angularVelocity = (entity.angularVelocity - delta.x * HOLD_SPIN) * area.friction; rotation = std::clamp(rotation * area.friction, -ROTATION_MAX, ROTATION_MAX); int removed = -1; if (item.use == USE_EDIBLE) { - bool isEdible = item.calories > 0 && progress.calories + item.calories <= capacity_max_get(); - if (isJustItemHeld) - text_play(isEdible ? (isOver ? "FeedFull" : "Feed") - : item.calories > progress.capacity ? "LowCapacity" - : "Full"); - isJustItemHeld = false; - for (EatArea& eatArea : data.eatAreas) + bool isEdible = item.calories > 0 && stats.calories + item.calories <= body.capacity_max_get(); + if (isJustHeld) + text_play(isEdible ? (isOver ? HOOK_FEED_FULL : HOOK_FEED) + : item.calories > stats.capacity ? HOOK_LOW_CAPACITY + : HOOK_FULL); + isJustHeld = false; + for (EatArea& eatArea : character.eatAreas) { - Rect rect = eat_rect_get(eatArea); - bool isInside = is_point_in_rect(rect, held.actor.position); + Rect rect = body.eat_rect_get(eatArea); + bool isInside = is_point_in_rect(rect, entity.actor.position); bool isAreaActive = isEdible && isInside; - bool isPending = eatItem == heldItem && eatEvent == eatArea.event; - bool isPendingActive = isPending && is_point_in_rect(eatRect, held.actor.position); - std::string eatName = eatArea.animation + suffix_get(); + bool isPending = feeding.item == held && feeding.event == eatArea.event; + bool isPendingActive = isPending && is_point_in_rect(feeding.rect, entity.actor.position); + std::string eatName = eatArea.animation + body.suffix; if (isAreaActive || isPendingActive) { - if (isPending && eatArea.event >= 0 && actor.event == eatArea.event) + if (isPending && eatArea.event >= 0 && body.actor.event == eatArea.event) { - actor.event = -1; - progress.calories += item.calories; - progress.totalCalories += item.calories; - progress.capacity = std::clamp(progress.capacity + item.capacityBonus, data.capacityMin, - data.capacityMax); - progress.digestionRate = std::clamp(progress.digestionRate + item.digestionBonus, - data.digestionRateMin, data.digestionRateMax); - progress.totalConsumed++; - removed = heldItem; - item_drop(); - eatAnimation = actor.animation; - isEatTextPending = !pointer.isAutomated; - isEatTextFull = isOver; + Traits& traits = character.traits; + body.actor.event = -1; + stats.calories += item.calories; + stats.totalCalories += item.calories; + stats.capacity = + std::clamp(stats.capacity + item.capacityBonus, traits.capacityMin, traits.capacityMax); + stats.digestionRate = std::clamp(stats.digestionRate + item.digestionBonus, + traits.digestionRateMin, traits.digestionRateMax); + stats.totalConsumed++; + removed = held; + drop(); + feeding.animation = body.actor.animation; + feeding.isTextPending = !pointer.isAutomated; + feeding.isTextFull = isOver; break; } - eatItem = heldItem; - eatEvent = eatArea.event; + feeding.item = held; + feeding.event = eatArea.event; if (isAreaActive) - eatRect = rect; - if (!actor.is_playing(eatName)) - body_play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS); + feeding.rect = rect; + if (!body.actor.is_playing(eatName)) + body.play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS); } - else if (eatItem == heldItem) - item_drop(HOLD_KEEP); - if (actor.is_playing(eatName) && !isInside) - text_play(isOver ? "FoodTakenFull" : "FoodTaken"); + else if (feeding.item == held) + drop(HOLD_KEEP); + if (body.actor.is_playing(eatName) && !isInside) + text_play(isOver ? HOOK_FOOD_TAKEN_FULL : HOOK_FOOD_TAKEN); if (isAreaActive) break; } } else if (item.use == USE_TOOL && item.isToggleSpritesheet && - is_point_in_rect(actor.bounds_get(), held.actor.position) && data.alternateTexture && - data.alternateSheet < (int)actor.sheets.size()) + is_point_in_rect(body.actor.bounds_get(), entity.actor.position) && character.alternate.texture && + character.alternate.sheet < (int)body.actor.sheets.size()) { - progress.isAlternate = !progress.isAlternate; - actor.sheets[data.alternateSheet] = - progress.isAlternate ? data.alternateTexture : data.anm2->sheets[data.alternateSheet]; - audio->play(data.alternateSound); - removed = heldItem; - item_drop(); + stats.isAlternate = !stats.isAlternate; + body.actor.sheets[character.alternate.sheet] = + stats.isAlternate ? character.alternate.texture : character.anm2->sheets[character.alternate.sheet]; + audio.play(character.alternate.sound); + removed = held; + drop(); } if (removed < 0 && pointer.isReleased) { bool isThrown = std::abs(delta.x) >= THROW_THRESHOLD || std::abs(delta.y) >= THROW_THRESHOLD; - audio->play(data.sounds[isThrown ? SOUND_THROW : SOUND_RELEASE]); + audio.play(character.sounds[isThrown ? SOUND_THROW : SOUND_RELEASE]); if (isThrown) - text_play("Throw"); - held.velocity = held.velocity - delta; - held_release(isThrown); + text_play(HOOK_THROW); + entity.velocity = entity.velocity - delta; + release(isThrown); } if (removed >= 0) - items.erase(items.begin() + removed); + list.erase(list.begin() + removed); } int moved = -1; - bool isTargetLocked = pointer.target >= 0 && pointer.target < (int)items.size(); + bool isTargetLocked = pointer.target >= 0 && pointer.target < (int)list.size(); float right = bounds.x + bounds.w; float bottom = bounds.y + bounds.h; - for (int i = 0; i < (int)items.size(); i++) + for (int i = 0; i < (int)list.size(); i++) { - ItemEntity& entity = items[i]; + ItemEntity& entity = list[i]; Vec2& position = entity.actor.position; Vec2& velocity = entity.velocity; - float& rotation = entity.rotation_get(); - float gravity = data.items[entity.id].gravity != 0 ? data.items[entity.id].gravity : area.gravity; - entity.actor.update(*audio); + float& rotation = *entity.actor.rotation; + float gravity = character.catalog.items[entity.id].gravity; + gravity = gravity != 0 ? gravity : area.gravity; + entity.actor.update(audio); position = position + velocity; rotation += entity.angularVelocity; if (is_point_in_rect(entity.actor.bounds_get(), pointer.position) && !pointer.isCaptured && (!isTargetLocked || i == pointer.target)) { - isItemHovered = true; - tooltipItem = heldItem == i || pointer.isAutomated ? -1 : entity.id; - active.play(data.cursorHover, TRANSITION_IF_IDLE); - cursorState = CURSOR_HOVER; + isHovered = true; + tooltip = held == i || pointer.isAutomated ? -1 : entity.id; + active.play(character.cursor.hover, TRANSITION_IF_IDLE); + play.cursorState = CURSOR_HOVER; if (pointer.isClicked) { - audio->play(data.sounds[SOUND_GRAB]); - particles_spawn(data.grabParticles, pointer.position); - isJustItemHeld = true; + audio.play(character.sounds[SOUND_GRAB]); + play.particles_spawn(character.cursor.grabParticles, pointer.position); + isJustHeld = true; } if (pointer.isDown) { - active.play(data.cursorGrab); - cursorState = CURSOR_ACTION; - heldItem = moved = i; + active.play(character.cursor.grab); + play.cursorState = CURSOR_ACTION; + held = moved = i; } if (pointer.isRightClicked) { - audio->play(data.sounds[SOUND_DISPOSE]); - active.play(data.cursorDispose, TRANSITION_FORCE, INTERRUPT_NEVER); - particles_spawn(data.disposeParticles, position); - heldItem = heldItem == i ? -1 : heldItem > i ? heldItem - 1 : heldItem; + audio.play(character.sounds[SOUND_DISPOSE]); + active.play(character.cursor.dispose, TRANSITION_FORCE, INTERRUPT_NEVER); + play.particles_spawn(character.catalog.disposeParticles, position); + held = held == i ? -1 : held > i ? held - 1 : held; moved = moved == i ? -1 : moved > i ? moved - 1 : moved; - items.erase(items.begin() + i--); + list.erase(list.begin() + i--); continue; } } - if (i != heldItem) + if (i != held) { if (position.x <= bounds.x || position.x >= right) { @@ -292,7 +278,7 @@ void Play::items_update(Pointer pointer) velocity.x = -velocity.x * area.friction; entity.angularVelocity = -entity.angularVelocity * area.friction; rotation = -rotation; - audio->play(data.sounds[SOUND_BOUNCE]); + audio.play(character.sounds[SOUND_BOUNCE]); } if (position.y <= bounds.y || position.y >= bottom) { @@ -301,7 +287,7 @@ void Play::items_update(Pointer pointer) position.y = bottom; velocity = {velocity.x * area.friction, 0}; rotation = std::remainder(rotation, 360.0f); - float step = ROTATION_RETURN_SPEED * ImGui::GetIO().DeltaTime; + float step = ROTATION_RETURN_SPEED * TICK; rotation = std::abs(rotation) <= step ? 0 : rotation - std::copysign(step, rotation); entity.angularVelocity *= ROTATION_GROUND_DAMPING; } @@ -309,7 +295,7 @@ void Play::items_update(Pointer pointer) { velocity.y = -velocity.y; entity.angularVelocity *= area.friction; - audio->play(data.sounds[SOUND_BOUNCE]); + audio.play(character.sounds[SOUND_BOUNCE]); } velocity.y *= area.friction; } @@ -319,276 +305,14 @@ void Play::items_update(Pointer pointer) position = {std::clamp(position.x, bounds.x, right), std::clamp(position.y, bounds.y, bottom)}; } - if (moved >= 0 && moved < (int)items.size() - 1) + if (moved >= 0 && moved < (int)list.size() - 1) { - ItemEntity entity = std::move(items[moved]); - items.erase(items.begin() + moved); - items.push_back(std::move(entity)); - heldItem = (int)items.size() - 1; + ItemEntity entity = std::move(list[moved]); + list.erase(list.begin() + moved); + list.push_back(std::move(entity)); + held = (int)list.size() - 1; } - if (wasItemHovered && !isItemHovered) - active.play(data.cursorIdle); - itemPointerPrevious = pointer.position; -} - -void Play::interact_update(Pointer pointer) -{ - Character& data = *character; - Actor& active = pointer_actor_get(*this); - if (text.isScene) - { - isInteracting = isHoldInteracting = isHovering = false; - interactArea = -1; - return; - } - bool wasInteracting = isInteracting; - bool wasHolding = isHoldInteracting; - bool wasHovering = isHovering; - isHovering = false; - isHoldInteracting &= isInteracting; - dialogueCooldowns.resize(data.interactAreas.size()); - for (int& cooldown : dialogueCooldowns) - cooldown = text.entry ? std::max(0, cooldown - 1) : 0; - - for (int i = 0; i < (int)data.interactAreas.size(); i++) - { - InteractArea& area = data.interactAreas[i]; - Rect rect = actor.null_rect_get(area.null); - bool isInside = is_point_in_rect(rect, pointer.position); - if (cursorState == CURSOR_DEFAULT && isInside && !pointer.isCaptured && area.type == pointer.type) - { - cursorState = CURSOR_HOVER; - active.play(area.cursorHover, TRANSITION_IF_IDLE); - isHovering = true; - interactArea = i; - bool isHoldStart = area.isHold && (pointer.isClicked || (pointer.isDown && !isInteracting)); - if (isHoldStart || (!area.isHold && pointer.isClicked)) - { - if (area.isHold) - isInteracting = isHoldInteracting = true; - else - { - cursorState = CURSOR_ACTION; - active.play(area.cursorActive, TRANSITION_FORCE, INTERRUPT_ALWAYS); - } - audio->play(area.sounds); - idle_override_set(area.idleOnEnd); - particles_spawn(area.particles, pointer.position); - if (area.digestionClick > 0 && progress.calories > 0 && !progress.isDigesting) - progress.digestionProgress += area.digestionClick; - if (area.layer >= 0) - actor.overrides.push_back({.amplitude = {area.scaleAmplitude, area.scaleAmplitude}, - .cycles = area.scaleCycles, - .time = area.scaleTime, - .timeStart = area.scaleTime, - .id = area.layer}); - bool isReady = dialogueCooldowns[i] <= 0; - dialogueCooldowns[i] = std::max(0, area.dialogueCooldown); - bool isFull = is_over_capacity() && data.dialogue.pools.contains(area.dialoguePoolFull); - const std::string& pool = isFull ? area.dialoguePoolFull : area.dialoguePool; - if (data.dialogue.pools.contains(pool)) - { - if (text.is_interruptible() && isReady) - text.set(*this, data.dialogue.pool_get(pool), TEXT_INTERRUPTIBLE, - area.isHold ? "" : area.idleOnEnd); - } - else if (area.dialoguePool.empty() && !area.animation.empty()) - body_play(area.animation, TRANSITION_FORCE); - } - if (isInteracting) - { - cursorState = CURSOR_ACTION; - active.play(area.cursorActive); - Vec2 moved = pointer.position - pointerPrevious; - if (area.digestionHover > 0 && progress.calories > 0 && !progress.isDigesting && - (moved.x != 0 || moved.y != 0)) - progress.digestionProgress += area.digestionHover; - } - } - if ((i == interactArea && !isInside) || pointer.isReleased || pointer.isCaptured) - { - isInteracting = isHoldInteracting = false; - interactArea = -1; - } - } - - if ((wasInteracting && !isInteracting) || (wasHovering && !isHovering)) - active.play(data.cursorIdle); - if (wasHolding && !isHoldInteracting && !actor.pending) - idle_play(); + if (wasHovered && !isHovered) + active.play(character.cursor.idle); pointerPrevious = pointer.position; - if (isJustDigested && text.is_interruptible()) - text.set(*this, data.dialogue.hook_get("Digest")); - if (isJustStageUp) - text.set(*this, data.dialogue.hook_get("StageUp")); -} - -// Moves the cursor toward a target, braking to land on it. Returns whether it's there. -static bool is_target_reached(Vec2& position, Vec2& velocity, Vec2 target, float speed, float acceleration) -{ - Vec2 delta = target - position; - float distance = vec2_length(delta); - if (!std::isfinite(distance)) - return false; - if (distance <= AUTO_TARGET_EPSILON) - { - position = target; - velocity = {}; - return true; - } - Vec2 desired = delta / distance * std::min(speed, std::sqrt(AUTO_BRAKING * acceleration * distance)); - Vec2 change = desired - velocity; - float changeLength = vec2_length(change); - velocity = changeLength > acceleration ? velocity + change / changeLength * acceleration : desired; - if (vec2_length(velocity) >= distance) - { - position = target; - velocity = {}; - return true; - } - position = position + velocity; - return false; -} - -static bool is_autofeed_food(const Item& item) -{ - return item.use == USE_EDIBLE && item.capacityBonus >= 0 && item.digestionBonus >= 0; -} - -static bool is_edible(Play& play, const Item& item) -{ - return is_autofeed_food(item) && item.calories > 0 && play.progress.calories + item.calories <= play.capacity_max_get(); -} - -// The edible item in the world with the most calories, ties broken at random. -static int food_best_get(Play& play) -{ - int best = -1; - int ties = 0; - for (int i = 0; i < (int)play.items.size(); i++) - { - Item& item = play.character->items[play.items[i].id]; - if (!is_edible(play, item)) - continue; - float bestCalories = best >= 0 ? play.character->items[play.items[best].id].calories : -1; - if (item.calories > bestCalories) - best = i, ties = 1; - else if (item.calories == bestCalories && SDL_rand(++ties) == 0) - best = i; - } - return best; -} - -// Plays for the player: spawns food, carries it to the mouth, and rubs or pats the belly along when nothing fits. -void Play::autofeed_update(Pointer& itemPointer, Pointer& interactPointer) -{ - Character& data = *character; - isAutofeedActive = false; - autoCursor.isVisible = false; - auto reset = [&]() { - isAutoItemHeld = isAutoDigestHeld = false; - autoTarget = -1; - autoClickCooldown = 0; - autoVelocity = {}; - }; - bool isWorldFood = false; - bool isAnyEdible = false; - for (ItemEntity& entity : items) - { - isWorldFood |= is_autofeed_food(data.items[entity.id]); - isAnyEdible |= is_edible(*this, data.items[entity.id]); - } - if (!isAutofeed || isStageUp || isJustStageUp || isJustStageFinal) - return reset(); - spawn_queue_add(); - if (progress.calories <= AUTO_CALORIE_EPSILON && !isWorldFood) - return reset(); - - if (autoCursor.position.x == 0 && autoCursor.position.y == 0) - autoCursor.position = actor.position; - Rect bodyBounds = actor.bounds_get(); - float speedScale = is_rect_valid(bodyBounds) ? std::clamp(std::sqrt(bodyBounds.w * bodyBounds.h / AUTO_SCALE_AREA), - AUTO_SCALE_MIN, AUTO_SCALE_MAX) - : 1; - Rect mouth = {NAN, NAN, NAN, NAN}; - for (EatArea& eatArea : data.eatAreas) - if (!is_rect_valid(mouth)) - mouth = eat_rect_get(eatArea); - bool isMouth = is_rect_valid(mouth); - auto move = [&](Vec2 target, float speed, float acceleration) { - isAutofeedActive = autoCursor.isVisible = true; - itemPointer = interactPointer = {.position = autoCursor.position, .isAutomated = true}; - bool isThere = is_target_reached(autoCursor.position, autoVelocity, target, speed * speedScale, - acceleration * speedScale); - itemPointer.position = interactPointer.position = autoCursor.position; - return isThere; - }; - - if (heldItem >= 0 && heldItem < (int)items.size()) - { - if (isMouth && is_edible(*this, data.items[items[heldItem].id])) - { - move(rect_middle(mouth), AUTO_SPEED, AUTO_ACCELERATION); - itemPointer.isDown = isAutoItemHeld = true; - isAutoDigestHeld = false; - return; - } - move(autoCursor.position, AUTO_SPEED, AUTO_ACCELERATION); - itemPointer.isReleased = true; - isAutoItemHeld = false; - autoTarget = -1; - return; - } - - bool isTargetValid = autoTarget >= 0 && autoTarget < (int)items.size() && - is_edible(*this, data.items[items[autoTarget].id]); - if (!isMouth || !isTargetValid) - autoTarget = isMouth ? food_best_get(*this) : -1; - if (autoTarget >= 0) - { - Rect rect = items[autoTarget].actor.bounds_get(); - if (!is_rect_valid(rect)) - return reset(); - move(rect_middle(rect), AUTO_SPEED, AUTO_ACCELERATION); - bool isInside = is_point_in_rect(rect, autoCursor.position); - itemPointer.target = autoTarget; - itemPointer.isClicked = isInside && !isAutoItemHeld; - itemPointer.isDown = isInside || isAutoItemHeld; - isAutoItemHeld |= isInside; - isAutoDigestHeld = false; - return; - } - if (!(isWorldFood || !isAnyEdible)) - return reset(); - - InteractArea* digestArea = nullptr; - for (InteractArea& area : data.interactAreas) - if (!digestArea && (area.digestionClick > 0 || area.digestionHover > 0)) - digestArea = &area; - Rect rect = digestArea ? actor.null_rect_get(digestArea->null) : Rect{NAN, NAN, NAN, NAN}; - if (!is_rect_valid(rect)) - return reset(); - autoAngle += AUTO_ANGLE_STEP; - float radius = std::max(1.0f, std::min(rect.w, rect.h) * AUTO_DIGEST_RADIUS); - Vec2 target = rect_middle(rect) + Vec2{std::cos(autoAngle), std::sin(autoAngle)} * radius; - bool isThere = move(target, AUTO_DIGEST_SPEED, AUTO_DIGEST_ACCELERATION); - interactPointer.type = digestArea->type; - if (digestArea->digestionHover > 0) - { - bool isInside = is_point_in_rect(rect, autoCursor.position); - interactPointer.isClicked = isInside && !isAutoDigestHeld; - interactPointer.isDown = isInside; - interactPointer.isReleased = isAutoDigestHeld && !isInside; - isAutoDigestHeld = isInside; - } - else - { - autoClickCooldown = std::max(0, autoClickCooldown - 1); - interactPointer.isClicked = isThere && autoClickCooldown <= 0 && !isAutoDigestHeld; - interactPointer.isReleased = isAutoDigestHeld; - if (interactPointer.isClicked) - autoClickCooldown = AUTO_CLICK_COOLDOWN; - isAutoDigestHeld = interactPointer.isClicked; - } - isAutoItemHeld = false; } diff --git a/src/main.cpp b/src/main.cpp index 8ba1732..b1b5171 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,7 +3,6 @@ #include "ui.h" #include #include -#include #include #include @@ -29,11 +28,13 @@ enum Screen SCREEN_PLAY }; -// Everything the game holds, from start to exit. +// Everything the game holds, from start to exit. Long-lived parts keep pointers to the shared ones (the audio, +// renderer and command list) set here once. struct App { std::vector characters; Audio audio; + Commands commands; Console console; Play play; Renderer renderer; @@ -44,55 +45,54 @@ struct App SDL_Window* window = nullptr; Uint64 tickPrevious = 0; Screen screen = SCREEN_SELECT; + bool isCursorHidden = false; bool isRunning = true; }; -static App game; +static App app; static int character_get(const std::string& key) { - for (int i = 0; i < (int)game.characters.size(); i++) - if (game.characters[i].key == key) + for (int i = 0; i < (int)app.characters.size(); i++) + if (app.characters[i].key == key) return i; return -1; } static void play_exit() { - if (game.screen != SCREEN_PLAY) + if (app.screen != SCREEN_PLAY) return; - game.play.save(); - game.settings.lastCharacter.clear(); - ImGui::GetIO().FontDefault = game.font; - style_set(game.settings.color); - widget_sounds_set(nullptr, nullptr, nullptr); - game.screen = SCREEN_SELECT; + app.play.save(); + app.settings.lastCharacter.clear(); + ImGui::GetIO().FontDefault = app.font; + style_set(app.settings.color); + app.screen = SCREEN_SELECT; } -static void play_start(int index, Game mode) +static void play_start(const std::string& key, Game game) { + int index = character_get(key); if (index < 0) return; play_exit(); - Character& character = game.characters[index]; - game.ui.characterIndex = index; - game.play.set(character, mode, game.renderer, game.audio); - game.settings.lastCharacter = character.key; - ImGui::GetIO().FontDefault = character.font ? character.font : game.font; - style_set(game.settings.isCharacterColor ? character.color : game.settings.color, character.rounding); - widget_sounds_set(&game.audio, &character.sounds[SOUND_HOVER], &character.sounds[SOUND_SELECT]); - game.ui.itemActors.clear(); - game.ui.selectedItem = -1; - game.screen = SCREEN_PLAY; + Character& character = app.characters[index]; + app.ui.characterIndex = index; + app.play.set(character, game, app.renderer, app.audio); + app.settings.lastCharacter = character.key; + ImGui::GetIO().FontDefault = character.menu.font ? character.menu.font : app.font; + style_set(app.settings.isCharacterColor ? character.color : app.settings.color, character.menu.rounding); + app.screen = SCREEN_PLAY; } static void characters_reload() { - std::string key = game.screen == SCREEN_PLAY ? game.play.character->key : ""; + std::string key = app.screen == SCREEN_PLAY ? app.play.character->key : ""; play_exit(); - game.ui.previewActor = {}; - game.characters = characters_load(game.renderer); - play_start(character_get(key), GAME_CONTINUE); + app.ui.previewActor = {}; + app.ui.catalogCharacter = nullptr; + app.characters = characters_load(app.renderer); + play_start(key, GAME_CONTINUE); } static void init() @@ -100,29 +100,29 @@ static void init() if (!SDL_Init(SDL_INIT_VIDEO)) { SDL_Log("Failed to initialize SDL: %s", SDL_GetError()); - game.isRunning = false; + app.isRunning = false; return; } - game.settings.load(); + app.settings.load(); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, GL_MAJOR); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, GL_MINOR); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, GL_PROFILE); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - game.window = SDL_CreateWindow(GAME_NAME, game.settings.width, game.settings.height, - SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE); - SDL_SetWindowPosition(game.window, game.settings.x, game.settings.y); - game.context = game.window ? SDL_GL_CreateContext(game.window) : nullptr; - if (game.context) - game.renderer.init(); - if (!game.renderer.isValid) + app.window = + SDL_CreateWindow(GAME_NAME, app.settings.width, app.settings.height, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE); + SDL_SetWindowPosition(app.window, app.settings.x, app.settings.y); + app.context = app.window ? SDL_GL_CreateContext(app.window) : nullptr; + if (app.context) + app.renderer.init(); + if (!app.renderer.isValid) { SDL_Log("Failed to initialize OpenGL: %s", SDL_GetError()); - game.isRunning = false; + app.isRunning = false; return; } SDL_GL_SetSwapInterval(1); - game.audio.init(); - game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT); + app.audio.init(); + app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT); ImGui::CreateContext(); ImGuiIO& io = ImGui::GetIO(); @@ -131,14 +131,19 @@ static void init() io.KeyRepeatDelay = KEY_REPEAT_DELAY; io.KeyRepeatRate = KEY_REPEAT_RATE; ImGui::StyleColorsDark(); - ImGui_ImplSDL3_InitForOpenGL(game.window, game.context); + ImGui_ImplSDL3_InitForOpenGL(app.window, app.context); ImGui_ImplOpenGL3_Init(GLSL_VERSION); - game.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL); - io.FontDefault = game.font; - style_set(game.settings.color); + app.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL); + io.FontDefault = app.font; + style_set(app.settings.color); - game.characters = characters_load(game.renderer); - play_start(character_get(game.settings.lastCharacter), GAME_CONTINUE); + app.ui.audio = &app.audio; + app.ui.renderer = &app.renderer; + app.ui.commands = &app.commands; + app.console.renderer = &app.renderer; + app.console.commands = &app.commands; + app.characters = characters_load(app.renderer); + play_start(app.settings.lastCharacter, GAME_CONTINUE); } static void events_handle() @@ -148,94 +153,94 @@ static void events_handle() { ImGui_ImplSDL3_ProcessEvent(&event); if (event.type == SDL_EVENT_QUIT) - game.isRunning = false; + app.isRunning = false; if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) continue; bool isTyping = ImGui::GetIO().WantTextInput; bool isAltEnter = event.key.key == SDLK_RETURN && (event.key.mod & SDL_KMOD_ALT); if (event.key.key == SDLK_F11 || isAltEnter || (!isTyping && event.key.scancode == SDL_SCANCODE_F)) - game.ui.isFullscreenToggled = true; + app.commands.push_back({"", COMMAND_FULLSCREEN}); if (!isTyping && event.key.scancode == SDL_SCANCODE_M) { - game.settings.isMuted = !game.settings.isMuted; - game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT); + app.settings.isMuted = !app.settings.isMuted; + app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT); } - if (event.key.scancode == SDL_SCANCODE_GRAVE && game.settings.isConsole) - game.console.isOpen = !game.console.isOpen; + if (event.key.scancode == SDL_SCANCODE_GRAVE && app.settings.isConsole) + app.console.isOpen = !app.console.isOpen; + } +} + +// Carries out what the UI, console and shortcuts asked for this tick. +static void commands_handle() +{ + Commands commands = std::move(app.commands); + app.commands.clear(); + for (Command& command : commands) + { + if (command.type == COMMAND_CONTINUE || command.type == COMMAND_NEW_GAME) + play_start(command.character, command.type == COMMAND_NEW_GAME ? GAME_NEW : GAME_CONTINUE); + if (command.type == COMMAND_MENU) + play_exit(); + if (command.type == COMMAND_SAVE && app.screen == SCREEN_PLAY) + app.play.save(); + if (command.type == COMMAND_RELOAD) + characters_reload(); + if (command.type == COMMAND_FULLSCREEN) + SDL_SetWindowFullscreen(app.window, !(SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN)); } } static void update() { - Play& play = game.play; - Ui& ui = game.ui; - Vec2 screen = {ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y}; - if (game.screen == SCREEN_SELECT) - { - ui.select_update(game.characters, game.settings, game.renderer, game.audio); - if (ui.isRefresh) - characters_reload(); - if (ui.isNewGame || ui.isContinue) - play_start(ui.characterIndex, ui.isNewGame ? GAME_NEW : GAME_CONTINUE); - ui.isNewGame = ui.isContinue = false; - } + ImVec2 display = ImGui::GetIO().DisplaySize; + if (app.screen == SCREEN_SELECT) + app.ui.select_update(app.characters, app.settings); else { - ui.play_update(play, game.settings, game.renderer, game.audio); - play.update(screen); - if (std::exchange(ui.isSave, false)) - play.save(); - if (std::exchange(ui.isReturn, false)) - play_exit(); + app.ui.play_update(app.play, app.settings); + app.play.update({display.x, display.y}); } + app.console.update(app.screen == SCREEN_PLAY ? &app.play : nullptr, app.characters); + commands_handle(); - Console& console = game.console; - console.update(game.screen == SCREEN_PLAY ? &play : nullptr, game.characters); - if (console.action == ACTION_MENU) - play_exit(); - if (console.action == ACTION_CHARACTER || console.action == ACTION_NEW_GAME) - play_start(character_get(console.target), console.action == ACTION_NEW_GAME ? GAME_NEW : GAME_CONTINUE); - if (console.action == ACTION_RELOAD) - characters_reload(); - - if (std::exchange(ui.isFullscreenToggled, false)) - SDL_SetWindowFullscreen(game.window, !(SDL_GetWindowFlags(game.window) & SDL_WINDOW_FULLSCREEN)); - ui.isFullscreen = SDL_GetWindowFlags(game.window) & SDL_WINDOW_FULLSCREEN; - if (!ui.isFullscreen) + app.ui.isFullscreen = SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN; + if (!app.ui.isFullscreen) { - SDL_GetWindowSize(game.window, &game.settings.width, &game.settings.height); - SDL_GetWindowPosition(game.window, &game.settings.x, &game.settings.y); + SDL_GetWindowSize(app.window, &app.settings.width, &app.settings.height); + SDL_GetWindowPosition(app.window, &app.settings.x, &app.settings.y); } - bool isCursorHidden = game.screen == SCREEN_PLAY && play.character->cursor && !console.isOpen; - isCursorHidden ? SDL_HideCursor() : SDL_ShowCursor(); + bool isCursorHidden = app.screen == SCREEN_PLAY && app.play.cursor.anm2 && !app.console.isOpen; + if (isCursorHidden != app.isCursorHidden) + isCursorHidden ? SDL_HideCursor() : SDL_ShowCursor(); + app.isCursorHidden = isCursorHidden; } static void render() { - Play& play = game.play; - Renderer& renderer = game.renderer; - bool isPlaying = game.screen == SCREEN_PLAY; + Play& play = app.play; + Renderer& renderer = app.renderer; + bool isPlaying = app.screen == SCREEN_PLAY; ImVec2 screen = ImGui::GetIO().DisplaySize; - Color background = isPlaying && game.settings.isCharacterColor ? play.character->color : game.settings.color; + Color background = isPlaying && app.settings.isCharacterColor ? play.character->color : app.settings.color; renderer.canvas_set(nullptr, (int)screen.x, (int)screen.y, background); if (isPlaying) play.render(renderer, {screen.x, screen.y}); renderer.flush(); ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); - if (isPlaying && play.character->cursor && !game.console.isOpen) + if (app.isCursorHidden) play.cursor.render(renderer, {}); renderer.flush(); - SDL_GL_SwapWindow(game.window); + SDL_GL_SwapWindow(app.window); } // Runs a tick (sixty a second) once its time comes. static void loop() { Uint64 now = SDL_GetTicks(); - if (now - game.tickPrevious < TICK_MILLISECONDS) + if (now - app.tickPrevious < TICK_MILLISECONDS) return SDL_Delay(1); - game.tickPrevious = now; + app.tickPrevious = now; events_handle(); ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplSDL3_NewFrame(); @@ -247,24 +252,24 @@ static void loop() static void quit() { play_exit(); - game.settings.save(); + app.settings.save(); ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(); - SDL_GL_DestroyContext(game.context); - SDL_DestroyWindow(game.window); + SDL_GL_DestroyContext(app.context); + SDL_DestroyWindow(app.window); SDL_Quit(); } int main(int, char**) { init(); - if (!game.isRunning) + if (!app.isRunning) return 1; #ifdef SDL_PLATFORM_EMSCRIPTEN emscripten_set_main_loop(loop, 0, true); #else - while (game.isRunning) + while (app.isRunning) loop(); #endif quit(); diff --git a/src/play.cpp b/src/play.cpp index 3fee2f8..c96ccd9 100644 --- a/src/play.cpp +++ b/src/play.cpp @@ -4,10 +4,13 @@ #include constexpr Rect WORLD_BOUNDS = {96, 54, 1728, 972}; -constexpr ImGuiKey CHEAT_CODE[] = {ImGuiKey_UpArrow, ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_DownArrow, - ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, - ImGuiKey_B, ImGuiKey_A}; -constexpr const char* AUTOFEED_TYPE = "Rub"; +constexpr ImGuiKey CHEAT_CODE[] = {ImGuiKey_UpArrow, ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_DownArrow, + ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, + ImGuiKey_B, ImGuiKey_A}; +constexpr ImGuiKey CHEAT_KEYS[] = {ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_LeftArrow, + ImGuiKey_RightArrow, ImGuiKey_B, ImGuiKey_A}; +constexpr const char* KEY_DISCOVERED = "discovered"; +constexpr const char* KEY_ITEM = "item"; constexpr float AUTOSAVE_TIME = 30; constexpr float CHEAT_TIME = 5; constexpr float FOCUS_MENU_WIDTH = 0.3f; @@ -17,18 +20,14 @@ constexpr float SHAKE_DECAY = 0.97f; constexpr float SHAKE_EPSILON = 0.1f; constexpr float SHAKE_LERP = 0.72f; constexpr float SHAKE_MAGNITUDE = 0.1f; -constexpr float TALK_STEP = 0.5f; -constexpr int OVERRIDE_TALK = 0; -constexpr int OVERRIDE_BLINK = 1; -constexpr int OVERRIDE_EXPAND = 2; -// Keeps a value within a range given either way round, putting what isn't a number at its low end. -static float value_clamp(float value, float low, float high) +struct SavedItem { - if (low > high) - std::swap(low, high); - return std::isfinite(value) ? std::clamp(value, low, high) : low; -} + std::string name; + Vec2 position; + Vec2 velocity; + float rotation = 0; +}; static int zoom_index_get(float zoom) { @@ -39,116 +38,99 @@ static int zoom_index_get(float zoom) return index; } +// Starts a character, from its save unless it's a new game. A save is the body's stats, the play's own fields (see +// visit), the items found (one "discovered" line each) and those lying in the world ("item" lines of position, +// velocity, rotation and name). void Play::set(Character& played, Game game, Renderer& renderer, Audio& sound) { played.load(renderer, sound); *this = Play(); character = &played; audio = &sound; + camera.zoom = 0; + + std::vector found; + std::vector saved; if (game == GAME_CONTINUE) - progress.load(played.save_path_get()); - if (!progress.isValid) - { - progress.weight = played.weight; - progress.capacity = played.capacity; - progress.digestionRate = played.digestionRate; - } + is_file_read(played.save_path_get(), [&](const std::string& key, const char* value) { + field_line_read(body.stats, key, value); + field_line_read(*this, key, value); + SavedItem item; + int length = 0; + if (key == KEY_DISCOVERED) + found.emplace_back(value); + else if (key == KEY_ITEM && std::sscanf(value, "%f %f %f %f %f %n", &item.position.x, &item.position.y, + &item.velocity.x, &item.velocity.y, &item.rotation, &length) == 5) + saved.push_back({value + length, item.position, item.velocity, item.rotation}); + }); + body.set(played, sound, game); + area_set(); - actor = Actor(played.anm2); - actor.overrides.resize(OVERRIDE_EXPAND); - actor.overrides[OVERRIDE_TALK].id = played.talkDestination; - actor.overrides[OVERRIDE_BLINK].id = played.blinkDestination; - for (ExpandArea& expandArea : played.expandAreas) + Catalog& catalog = played.catalog; + items.discovered.assign(catalog.items.size(), false); + for (const std::string& name : found) + if (int id = catalog.item_get(name); id >= 0) + items.discovered[id] = true; + for (SavedItem& item : saved) { - actor.overrides.push_back({.id = expandArea.layer}); - actor.overrides.push_back({.id = expandArea.null, .target = TARGET_NULL}); + int id = catalog.item_get(item.name); + if (id >= 0) + items.add(*this, id, item.position, item.velocity, item.rotation); + else + SDL_Log("Saved item not found: %s", item.name.c_str()); } - values_clamp(); - stage = stage_get(progress.weight); - - bool isRolled = game == GAME_NEW && played.alternateTexture && is_chance(played.alternateChance); - progress.isAlternate |= isRolled; - if (isRolled) - audio->play(played.alternateSound); - if (progress.isAlternate && played.alternateSheet < (int)actor.sheets.size()) - actor.sheets[played.alternateSheet] = played.alternateTexture; cursorType = played.interactTypes.empty() ? -1 : 0; - if (played.cursor) + if (played.cursor.anm2) { - cursor = Actor(played.cursor); - autoCursor = Actor(played.cursor); + cursor = Actor(played.cursor.anm2); + autoCursor = Actor(played.cursor.anm2); autoCursor.isVisible = false; } - - spawnDelay = progress.spawnDelay; - discovered.assign(played.items.size(), false); - auto item_get = [&](const std::string& name) { - for (int i = 0; i < (int)played.items.size(); i++) - if (played.items[i].name == name) - return i; - return -1; - }; - for (const std::string& name : progress.discovered) - if (int id = item_get(name); id >= 0) - discovered[id] = true; - for (SavedItem& saved : progress.items) - if (int id = item_get(saved.name); id >= 0) - item_add(id, saved.position, saved.velocity, saved.rotation); - - isPostgame = progress.isPostgame || stage >= (int)played.stages.size(); + isPostgame |= body.stage >= (int)played.stages.size(); #ifdef DEBUG isCheats = true; #endif isCheats |= isPostgame; - - int areaIndex = played.area_get(stage); - if (areaIndex >= 0) - area = Actor(played.areas[areaIndex].anm2); - bounds = areaIndex >= 0 && is_rect_valid(played.areas[areaIndex].bounds) ? played.areas[areaIndex].bounds - : WORLD_BOUNDS; - idle_play(); - actor.update(*audio); - isFocusPending = true; + camera.isFocusPending = camera.zoom < ZOOM_LADDER[0]; + camera.zoom = camera.isFocusPending ? ZOOM_LADDER[0] : ZOOM_LADDER[zoom_index_get(camera.zoom)]; if (game == GAME_NEW) - text.set(*this, played.dialogue.hook_get("Start"), TEXT_UNINTERRUPTIBLE); + text.set(*this, played.dialogue.hook_get(HOOK_START), TEXT_UNINTERRUPTIBLE); } void Play::save() { if (!character) return; - progress.pan = pan; - progress.zoom = zoom; - progress.spawnDelay = spawnDelay; - progress.isPostgame = isPostgame; - progress.items.clear(); - for (ItemEntity& entity : items) - progress.items.push_back( - {character->items[entity.id].name, entity.actor.position, entity.velocity, entity.rotation_get()}); - progress.discovered.clear(); - for (int i = 0; i < (int)discovered.size(); i++) - if (discovered[i]) - progress.discovered.push_back(character->items[i].name); - progress.save(character->save_path_get()); - toast_add(STRING_ToastSaving); + std::string text = fields_write(body.stats) + fields_write(*this); + for (int i = 0; i < (int)items.discovered.size(); i++) + if (items.discovered[i]) + text += std::string(KEY_DISCOVERED) + "=" + character->catalog.items[i].name + "\n"; + for (ItemEntity& entity : items.list) + text += std::string(KEY_ITEM) + "=" + field_write(entity.actor.position) + " " + field_write(entity.velocity) + + " " + field_write(entity.actor.rotation.value_or(0)) + " " + character->catalog.items[entity.id].name + + "\n"; + file_write(character->save_path_get(), text); + toast_add("Saving..."); } -void Play::toast_add(StringId message) +// Shows the area of the body's stage, with its bounds. +void Play::area_set() { - toasts.push_back({character->strings[message], TOAST_LIFE}); + int index = character->area_get(body.stage); + if (index >= 0 && area.anm2 != character->areas[index].anm2) + area = Actor(character->areas[index].anm2); + bounds = index >= 0 && is_rect_valid(character->areas[index].bounds) ? character->areas[index].bounds : WORLD_BOUNDS; } -void Play::shake_add(float magnitude, int time) +void Play::toast_add(const std::string& message) { - shakeTimeMax = std::max(1, time); - shakeTime = shakeTimeMax; - shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)}; + toasts.push_back({message, TOAST_LIFE}); } -Vec2 Play::world_get(Vec2 screen) +Actor& Play::pointer_actor_get() { - return pan + screen / (zoom / PERCENT); + return autofeed.isActive ? autoCursor : cursor; } Focus Play::focus_get() @@ -158,254 +140,209 @@ Focus Play::focus_get() return isMenuOpen && isToolsOpen ? FOCUS_BOTH : isMenuOpen ? FOCUS_MENU : isToolsOpen ? FOCUS_TOOLS : FOCUS_CENTER; } -// Zooms and pans so the character fills the view, leaving room for the open panels. void Play::focus(Vec2 screenSize) { - Rect rect = actor.bounds_get(); - if (!is_rect_valid(rect)) + camera.focus(body.actor.bounds_get(), screenSize, focus_get()); +} + +void Play::particles_spawn(const std::vector& names, Vec2 position) +{ + for (const std::string& name : names) + { + auto found = character->particles.find(name); + if (found == character->particles.end() || !found->second.anm2) + SDL_Log("Particle not found: %s", name.c_str()); + else + particles.emplace_back(found->second.anm2, position, found->second.animation); + } +} + +// The Konami code unlocks the cheats menu, keyed within a few seconds of each press. +void Play::cheats_update() +{ + if (isCheats) return; - rect = rect_center(rect, FOCUS_PADDING); - float factor = std::min(screenSize.x / rect.w, screenSize.y / rect.h); + for (ImGuiKey key : CHEAT_KEYS) + { + if (!ImGui::IsKeyPressed(key, false)) + continue; + cheatIndex = key == CHEAT_CODE[cheatIndex] ? cheatIndex + 1 : key == CHEAT_CODE[0] ? 1 : 0; + cheatTime = (float)ImGui::GetTime(); + if (cheatIndex < (int)std::size(CHEAT_CODE)) + continue; + isCheats = true; + cheatIndex = 0; + toast_add("Cheats unlocked!"); + audio->play(character->sounds[SOUND_CHEATS]); + } + if (cheatIndex > 0 && ImGui::GetTime() - cheatTime > CHEAT_TIME) + cheatIndex = 0; +} + +// Answers what the body did this tick: dialogue for digesting and stage ups, and the ending at the final stage. +void Play::events_handle(Vec2 screenSize) +{ + Dialogue& dialogue = character->dialogue; + for (Event event : events) + { + if (event == EVENT_DIGESTED && !text.isScene && text.is_interruptible()) + text.set(*this, dialogue.hook_get(HOOK_DIGEST)); + if (event == EVENT_STAGE_UP && !text.isScene) + text.set(*this, dialogue.hook_get(HOOK_STAGE_UP)); + if (event != EVENT_STAGE_FINAL || isPostgame || isPostgamePending) + continue; + isPostgame = isCheats = !dialogue.is_hook(HOOK_END); + if (isPostgame) + continue; + text.set(*this, dialogue.hook_get(HOOK_END), TEXT_UNINTERRUPTIBLE); + isMenuOpen = isToolsOpen = false; + body.stats.calories = 0; + body.stats.digestionProgress = 0; + items.drop(); + isPostgamePending = true; + focus(screenSize); + } + events.clear(); +} + +void Play::update(Vec2 screenSize) +{ + ImGuiIO& io = ImGui::GetIO(); + if (std::exchange(camera.isFocusPending, false)) + focus(screenSize); + body.stats.playtime += TICK; + cheats_update(); + text.update(*this); + if (isPostgamePending && (!text.entry || (text.entry->isTerminal && text.isFinished)) && !text.isScene) + { + isMenuOpen = isPostgame = isCheats = true; + isPostgamePending = false; + focus(screenSize); + } + + if (autofeed.isEnabled) + cursorType = autofeed.type_get(*this); + Pointer pointer; + pointer.position = camera.world_get({io.MousePos.x, io.MousePos.y}); + pointer.type = cursorType; + pointer.isCaptured = io.WantCaptureMouse; + pointer.isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + pointer.isDown = ImGui::IsMouseDown(ImGuiMouseButton_Left); + pointer.isReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + pointer.isRightClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Right); + Pointer itemPointer = pointer; + Pointer interactPointer = pointer; + autofeed.isActive = false; + if (text.isScene) + itemPointer = interactPointer = {.position = pointer.position, .isCaptured = true}; + else + autofeed.update(*this, itemPointer, interactPointer); + items.update(*this, itemPointer); + interactions.update(*this, interactPointer); + + if (cursor.anm2 && !autofeed.isActive && pointer.isClicked && !items.isHovered && !interactions.isHovering && + cursorState == CURSOR_DEFAULT) + cursor.play(character->cursor.click, TRANSITION_FORCE, INTERRUPT_ALWAYS); + + body.update(events); + events_handle(screenSize); + area_set(); + area.update(*audio); + + for (int i = 0; i < (int)particles.size(); i++) + { + particles[i].update(*audio); + if (!particles[i].isPlaying) + particles.erase(particles.begin() + i--); + } + for (Actor* each : {&cursor, &autoCursor}) + { + bool isActive = (each == &autoCursor) == autofeed.isActive; + if ((!isActive || cursorState == CURSOR_DEFAULT) && !each->pending) + each->play(character->cursor.idle, TRANSITION_IF_IDLE); + each->update(*audio); + } + cursorState = CURSOR_DEFAULT; + cursor.position = {io.MousePos.x, io.MousePos.y}; + camera.update(*this, screenSize); + + for (int i = 0; i < (int)toasts.size(); i++) + if (--toasts[i].life <= 0) + toasts.erase(toasts.begin() + i--); + if ((autosaveTime += TICK) > AUTOSAVE_TIME) + { + save(); + autosaveTime = 0; + } +} + +void Play::render(Renderer& renderer, Vec2 screenSize) +{ + Affine view = camera.view_get(screenSize); + area.render(renderer, view); + body.actor.render(renderer, view); + if (!text.isScene) + for (ItemEntity& entity : items.list) + entity.actor.render(renderer, view); + if (autofeed.isActive) + autoCursor.render(renderer, view); + for (Actor& particle : particles) + particle.render(renderer, view); +} + +Vec2 Camera::world_get(Vec2 screen) +{ + return pan + screen / (zoom / PERCENT); +} + +Affine Camera::view_get(Vec2 screenSize) +{ + float scale = zoom / PERCENT; + return {scale, 0, 0, scale, -pan.x * scale + shake.x * screenSize.x, -pan.y * scale + shake.y * screenSize.y}; +} + +void Camera::shake_add(float magnitude, int time) +{ + shakeTimeMax = std::max(1, time); + shakeTime = shakeTimeMax; + shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)}; +} + +// Zooms and pans so a target fills the view, leaving room for the open panels. +void Camera::focus(Rect target, Vec2 screenSize, Focus panels) +{ + if (!is_rect_valid(target)) + return; + target = rect_center(target, FOCUS_PADDING); + float factor = std::min(screenSize.x / target.w, screenSize.y / target.h); zoom = ZOOM_LADDER[zoom_index_get(std::clamp(factor * PERCENT, ZOOM_LADDER[0], std::end(ZOOM_LADDER)[-1]))]; factor = zoom / PERCENT; - pan = rect_middle(rect) - screenSize / factor / 2; - Focus panels = focus_get(); + pan = rect_middle(target) - screenSize / factor / 2; if (panels == FOCUS_MENU || panels == FOCUS_BOTH) pan.x += screenSize.x * FOCUS_MENU_WIDTH / factor / 2; if (panels == FOCUS_TOOLS || panels == FOCUS_BOTH) pan.x -= screenSize.x * FOCUS_TOOLS_WIDTH / factor / 2; } -float Play::capacity_max_get() -{ - return progress.capacity * character->overstuffedMultiplier; -} - -float Play::capacity_percent_get() -{ - float capacityMax = capacity_max_get(); - return capacityMax > 0 ? progress.calories / capacityMax : 0; -} - -bool Play::is_over_capacity() -{ - return progress.calories > progress.capacity; -} - -int Play::stage_get(float weight) -{ - std::vector& stages = character->stages; - if (stages.empty() || weight <= character->weight) - return 0; - for (int i = 0; i < (int)stages.size(); i++) - if (weight < stages[i].weight) - return i; - return (int)stages.size(); -} - -float Play::stage_threshold_get(int index) -{ - std::vector& stages = character->stages; - if (index <= 0 || stages.empty()) - return character->weight; - return stages[std::min(index, (int)stages.size()) - 1].weight; -} - -float Play::stage_progress_get() -{ - float low = stage_threshold_get(stage); - float high = stage_threshold_get(stage + 1); - if (stage >= (int)character->stages.size() || high <= low) - return 1; - return (progress.weight - low) / (high - low); -} - -std::string Play::suffix_get() -{ - return stage > 0 && stage <= (int)character->stages.size() ? character->stages[stage - 1].animationSuffix : ""; -} - -std::string Play::idle_get() -{ - if (isStageUp) - return ""; - if (!idleOverride.empty()) - return idleOverride; - return is_over_capacity() && !character->animationIdleFull.empty() ? character->animationIdleFull - : character->animationIdle; -} - -// Plays one of the character's animations, as its current stage draws it. -void Play::body_play(const std::string& animation, Transition transition, Interrupt interrupt) -{ - if (!animation.empty()) - actor.play(animation + suffix_get(), transition, interrupt); -} - -void Play::idle_play() -{ - body_play(idle_get()); -} - -void Play::idle_override_set(const std::string& animation) -{ - if (!animation.empty()) - idleOverride = animation == character->animationIdle ? "" : animation; -} - -void Play::values_clamp() -{ - Character& data = *character; - progress.weight = value_clamp(progress.weight, data.weight, data.weightMax); - progress.capacity = value_clamp(progress.capacity, data.capacityMin, data.capacityMax); - progress.digestionRate = value_clamp(progress.digestionRate, data.digestionRateMin, data.digestionRateMax); - progress.calories = value_clamp(progress.calories, 0, std::max(0.0f, capacity_max_get())); - progress.digestionProgress = value_clamp(progress.digestionProgress, 0, DIGESTION_MAX); - progress.digestionTimer = std::clamp(progress.digestionTimer, 0, std::max(0, data.digestionTime)); - progress.totalCalories = std::max(0.0f, progress.totalCalories); - progress.playtime = std::max(0.0f, progress.playtime); -} - -// Swaps a layer's region for the one a source layer shows at a time, while active. Turns itself off (and returns -// false) when the animation lacks the source or the time runs past it. -static bool is_region_swapped(Actor& actor, int source, float& time, Override& swap, bool isActive) -{ - Anm2Animation* animation = actor.animation_get(); - swap.isRegion = false; - if (!isActive || !animation) - return false; - for (Anm2Item& layer : animation->layers) - { - if (layer.id != source || layer.frames.empty()) - continue; - Anm2Frame frame = actor.frame_get(layer.frames, time, TARGET_LAYER, source); - swap = {.crop = frame.crop, .pivot = frame.pivot, .size = frame.size, .id = swap.id, .isRegion = true}; - time += TALK_STEP; - return true; - } - time = 0; - return false; -} - -static int source_length_get(Actor& actor, int source) -{ - Anm2Animation* animation = actor.animation_get(); - if (!animation) - return -1; - for (Anm2Item& layer : animation->layers) - if (layer.id == source) - return track_length_get(layer.frames); - return -1; -} - -void Play::body_update() -{ - Character& data = *character; - values_clamp(); - isJustStageUp = false; - isJustStageFinal = false; - isJustDigested = false; - if (!actor.isPlaying) - { - if (isStageUp) - { - (stage >= (int)data.stages.size() ? isJustStageFinal : isJustStageUp) = true; - isStageUp = false; - actor.isLocked = false; - } - if (!isTalking) - idle_play(); - } - actor.update(*audio); - - if (progress.isDigesting && --progress.digestionTimer <= 0) - { - float nextWeight = value_clamp(progress.weight + progress.calories * data.caloriesToKilogram, data.weight, - data.weightMax); - if (is_over_capacity()) - { - float over = progress.calories - progress.capacity; - float overPercent = over / (capacity_max_get() - progress.capacity); - progress.capacity = value_clamp(progress.capacity + overPercent * data.overstuffedDigestBonus * over, - data.capacityMin, data.capacityMax); - } - progress.calories = 0; - if (int nextStage = stage_get(nextWeight); nextStage > stage_get(progress.weight)) - { - actor.interrupt = INTERRUPT_IF_ALLOWED; - body_play(data.animationStageUp, TRANSITION_NORMAL, INTERRUPT_NEVER); - stage = nextStage; - isStageUp = true; - actor.isLocked = true; - } - else - isJustDigested = true; - progress.weight = nextWeight; - progress.isDigesting = false; - progress.digestionTimer = data.digestionTime; - progress.digestionProgress = 0; - } - else if (!progress.isDigesting) - { - if (progress.calories > 0) - progress.digestionProgress += progress.digestionRate; - if (progress.digestionProgress >= DIGESTION_MAX) - { - progress.isDigesting = true; - progress.digestionTimer = data.digestionTime; - audio->play(data.sounds[SOUND_DIGEST]); - } - } - values_clamp(); - if (is_chance(data.gurgleChance * capacity_percent_get() * data.gurgleCapacityMultiplier * PERCENT)) - audio->play(data.sounds[SOUND_GURGLE]); - stage = stage_get(progress.weight); - - float stageProgress = stage_progress_get(); - float capacityProgress = capacity_percent_get(); - if (progress.isDigesting) - capacityProgress *= (float)progress.digestionTimer / std::max(1, data.digestionTime); - for (int i = 0; i < (int)data.expandAreas.size(); i++) - { - float scaleMax = data.expandAreas[i].scaleMax; - float add = std::clamp((stageProgress + capacityProgress) * scaleMax / 2, 0.0f, scaleMax); - actor.overrides[OVERRIDE_EXPAND + i * 2].scale = {add, add}; - actor.overrides[OVERRIDE_EXPAND + i * 2 + 1].scale = {add, add}; - } - - isTalking = is_region_swapped(actor, data.talkSource, talkTime, actor.overrides[OVERRIDE_TALK], isTalking); - if (talkTime > source_length_get(actor, data.talkSource)) - talkTime = 0; - int blinkLength = source_length_get(actor, data.blinkSource); - if (blinkLength != 1) - { - isBlinking |= is_chance(data.blinkChance); - isBlinking = is_region_swapped(actor, data.blinkSource, blinkTime, actor.overrides[OVERRIDE_BLINK], isBlinking); - if (blinkTime >= blinkLength) - { - isBlinking = false; - blinkTime = 0; - actor.overrides[OVERRIDE_BLINK].isRegion = false; - } - } -} - -void Play::camera_update(Vec2 screenSize) +// Pans with the middle mouse (or control and the left), zooms through the ladder with the wheel toward the mouse, and +// settles the screen shake. +void Camera::update(Play& play, Vec2 screenSize) { ImGuiIO& io = ImGui::GetIO(); + Cursor& cursor = play.character->cursor; if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) { if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || (ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl))) { - cursor.play(character->cursorPan); + play.cursor.play(cursor.pan); pan = pan - Vec2{io.MouseDelta.x, io.MouseDelta.y} / (zoom / PERCENT); } if (io.MouseWheel != 0) { Vec2 mouse = {io.MousePos.x, io.MousePos.y}; Vec2 world = world_get(mouse); - cursor.play(character->cursorZoom); + play.cursor.play(cursor.zoom); int index = std::clamp(zoom_index_get(zoom) + (io.MouseWheel > 0 ? 1 : -1), 0, (int)std::size(ZOOM_LADDER) - 1); zoom = ZOOM_LADDER[index]; @@ -413,7 +350,7 @@ void Play::camera_update(Vec2 screenSize) } } if (ImGui::IsKeyPressed(ImGuiKey_Home)) - focus(screenSize); + play.focus(screenSize); if (shakeTime > 0) { @@ -430,152 +367,3 @@ void Play::camera_update(Vec2 screenSize) shake = {}; } } - -// The Konami code unlocks the cheats menu, keyed within a few seconds of each press. -void Play::cheats_update() -{ - if (isCheats) - return; - for (ImGuiKey key : {ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_B, - ImGuiKey_A}) - { - if (!ImGui::IsKeyPressed(key, false)) - continue; - cheatIndex = key == CHEAT_CODE[cheatIndex] ? cheatIndex + 1 : key == CHEAT_CODE[0] ? 1 : 0; - cheatTime = (float)ImGui::GetTime(); - if (cheatIndex < (int)std::size(CHEAT_CODE)) - continue; - isCheats = true; - cheatIndex = 0; - toast_add(STRING_ToastCheatsUnlocked); - audio->play(character->sounds[SOUND_CHEATS]); - } - if (cheatIndex > 0 && ImGui::GetTime() - cheatTime > CHEAT_TIME) - cheatIndex = 0; -} - -void Play::particles_spawn(const std::vector& names, Vec2 position) -{ - for (const std::string& name : names) - { - auto found = character->particles.find(name); - if (found == character->particles.end() || !found->second.anm2) - SDL_Log("Particle not found: %s", name.c_str()); - else - particles.emplace_back(found->second.anm2, position, found->second.animation); - } -} - -void Play::update(Vec2 screenSize) -{ - ImGuiIO& io = ImGui::GetIO(); - if (std::exchange(isFocusPending, false)) - { - focus(screenSize); - pan = progress.zoom >= ZOOM_LADDER[0] ? progress.pan : pan; - zoom = progress.zoom >= ZOOM_LADDER[0] ? ZOOM_LADDER[zoom_index_get(progress.zoom)] : zoom; - } - progress.playtime += io.DeltaTime; - cheats_update(); - text.update(*this, screenSize); - - if (isJustStageFinal && !isPostgame && !isPostgamePending) - { - Entry* end = character->dialogue.hook_get("End"); - isPostgame = isCheats = !end; - if (end) - { - text.set(*this, end, TEXT_UNINTERRUPTIBLE); - isMenuOpen = isToolsOpen = false; - progress.calories = 0; - progress.digestionProgress = 0; - item_drop(); - isPostgamePending = true; - focus(screenSize); - } - } - if (isPostgamePending && (!text.entry || (text.entry->isTerminal && text.isFinished)) && !text.isScene) - { - isMenuOpen = isPostgame = isCheats = true; - isPostgamePending = false; - focus(screenSize); - } - - if (isAutofeed) - for (int i = 0; i < (int)character->interactTypes.size(); i++) - if (character->interactTypes[i].name == AUTOFEED_TYPE) - cursorType = i; - Pointer pointer; - pointer.position = world_get({io.MousePos.x, io.MousePos.y}); - pointer.type = cursorType; - pointer.isCaptured = io.WantCaptureMouse; - pointer.isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); - pointer.isDown = ImGui::IsMouseDown(ImGuiMouseButton_Left); - pointer.isReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left); - pointer.isRightClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Right); - Pointer itemPointer = pointer; - Pointer interactPointer = pointer; - if (text.isScene) - { - itemPointer = interactPointer = {.position = pointer.position, .isCaptured = true}; - isAutofeedActive = false; - } - else - autofeed_update(itemPointer, interactPointer); - items_update(itemPointer); - interact_update(interactPointer); - - if (character->cursor && !isAutofeedActive && pointer.isClicked && !isItemHovered && !isHovering && - cursorState == CURSOR_DEFAULT) - cursor.play(character->cursorClick, TRANSITION_FORCE, INTERRUPT_ALWAYS); - - body_update(); - int areaIndex = character->area_get(stage); - if (areaIndex >= 0 && area.anm2 != character->areas[areaIndex].anm2) - area = Actor(character->areas[areaIndex].anm2); - if (areaIndex >= 0 && is_rect_valid(character->areas[areaIndex].bounds)) - bounds = character->areas[areaIndex].bounds; - area.update(*audio); - - for (int i = 0; i < (int)particles.size(); i++) - { - particles[i].update(*audio); - if (!particles[i].isPlaying) - particles.erase(particles.begin() + i--); - } - for (Actor* each : {&cursor, &autoCursor}) - { - bool isActive = (each == &autoCursor) == isAutofeedActive; - if ((!isActive || cursorState == CURSOR_DEFAULT) && !each->pending) - each->play(character->cursorIdle, TRANSITION_IF_IDLE); - each->update(*audio); - } - cursorState = CURSOR_DEFAULT; - cursor.position = {io.MousePos.x, io.MousePos.y}; - cursor.isVisible = true; - camera_update(screenSize); - - for (int i = 0; i < (int)toasts.size(); i++) - if (--toasts[i].life <= 0) - toasts.erase(toasts.begin() + i--); - if ((autosaveTime += io.DeltaTime) > AUTOSAVE_TIME) - { - save(); - autosaveTime = 0; - } -} - -void Play::render(Renderer& renderer, Vec2 screenSize) -{ - float scale = zoom / PERCENT; - Affine view = {scale, 0, 0, scale, -pan.x * scale + shake.x * screenSize.x, -pan.y * scale + shake.y * screenSize.y}; - area.render(renderer, view); - actor.render(renderer, view); - if (!text.isScene) - for (ItemEntity& entity : items) - entity.actor.render(renderer, view); - if (isAutofeedActive) - autoCursor.render(renderer, view); - for (Actor& particle : particles) - particle.render(renderer, view); -} diff --git a/src/play.h b/src/play.h index 2b34dd2..51e2ade 100644 --- a/src/play.h +++ b/src/play.h @@ -5,6 +5,7 @@ #include "save.h" constexpr float DIGESTION_MAX = 100; +constexpr float TICK = 1 / TICK_RATE; constexpr int TOAST_LIFE = 180; constexpr float ZOOM_LADDER[] = {100, 115, 130, 150, 175, 200, 235, 275, 325, 400}; @@ -14,12 +15,6 @@ enum Game GAME_CONTINUE }; -enum Hold -{ - HOLD_RELEASE, - HOLD_KEEP -}; - enum CursorState { CURSOR_DEFAULT, @@ -36,18 +31,32 @@ enum Focus FOCUS_BOTH }; +enum Hold +{ + HOLD_RELEASE, + HOLD_KEEP +}; + +// What happened to the body in a tick, answered once it's done. +enum Event +{ + EVENT_DIGESTED, + EVENT_STAGE_UP, + EVENT_STAGE_FINAL +}; + // The mouse as items and interact areas see it this tick: the player's, or the autofeed's stand-in. struct Pointer { Vec2 position; int target = -1; int type = -1; + bool isAutomated = false; bool isCaptured = false; bool isClicked = false; bool isDown = false; bool isReleased = false; bool isRightClicked = false; - bool isAutomated = false; }; struct ItemEntity @@ -56,11 +65,6 @@ struct ItemEntity Vec2 velocity; float angularVelocity = 0; int id = 0; - - float& rotation_get() - { - return actor.overrides[0].rotation; - } }; struct Toast @@ -69,120 +73,181 @@ struct Toast int life = 0; }; -class Renderer; -class Audio; -struct Settings; +class Play; -// A character being played: its body (weight, digestion, stages), the items in the world, what the cursor does to -// both, the autofeed, the camera and the effects. +// The character's body: its stats, digestion and stages, and the animations they choose. +class Body +{ +public: + Stats stats; + Actor actor; + std::string idleOverride; + std::string suffix; + Character* character = nullptr; + Audio* audio = nullptr; + float blinkTime = 0; + float talkTime = 0; + int stage = 0; + bool isBlinking = false; + bool isStageUp = false; + bool isTalking = false; + + void set(Character& played, Audio& sound, Game game); + void update(std::vector& events); + void play(const std::string& animation, Transition transition = TRANSITION_NORMAL, + Interrupt interrupt = INTERRUPT_IF_ALLOWED); + void idle_play(); + void idle_override_set(const std::string& animation); + void stage_set(int index); + void values_clamp(); + std::string idle_get(); + Rect eat_rect_get(const EatArea& eatArea); + float capacity_max_get(); + float capacity_percent_get(); + float stage_progress_get(); + float stage_threshold_get(int index); + int stage_get(float weight); + bool is_over_capacity(); +}; + +// Food being carried to an eat area, until the area's event fires. +struct Feeding +{ + Rect rect = {NAN, NAN, NAN, NAN}; + int animation = -1; + int event = -1; + int item = -1; + bool isTextFull = false; + bool isTextPending = false; +}; + +// The items in the world: spawning them, their physics, and carrying them to be eaten or used. +class Items +{ +public: + std::vector list; + std::vector spawnQueue; + std::vector discovered; + Feeding feeding; + Vec2 pointerPrevious; + int held = -1; + int spawnDelay = 0; + int tooltip = -1; + bool isHovered = false; + bool isJustHeld = false; + + void update(Play& play, Pointer pointer); + void add(Play& play, int id, Vec2 position, Vec2 velocity = {}, float rotation = 0); + void drop(Hold hold = HOLD_RELEASE); + void spawn_queue_add(Play& play, int id); + void spawn_queue_add(Play& play); + int spawn_delay_get(Play& play); + bool is_spawn_possible(Play& play); +}; + +// The cursor on the character's interact areas: rubbing, smacking and the like. +class Interactions +{ +public: + std::vector cooldowns; + Vec2 pointerPrevious; + int area = -1; + bool isHolding = false; + bool isHovering = false; + bool isInteracting = false; + + void update(Play& play, Pointer pointer); +}; + +// Plays for the player: spawns food, carries it to the mouth, and rubs or pats the belly along when nothing fits. +class Autofeed +{ +public: + Vec2 velocity; + float angle = 0; + int clickCooldown = 0; + int target = -1; + bool isActive = false; + bool isDigestHeld = false; + bool isEnabled = false; + bool isItemHeld = false; + + void update(Play& play, Pointer& itemPointer, Pointer& interactPointer); + int type_get(Play& play); + bool is_available(Play& play); + +private: + void reset(); +}; + +class Camera +{ +public: + Vec2 pan; + Vec2 shake; + float zoom = ZOOM_LADDER[0]; + int shakeTime = 0; + int shakeTimeMax = 0; + bool isFocusPending = false; + + void update(Play& play, Vec2 screenSize); + void focus(Rect target, Vec2 screenSize, Focus panels); + void shake_add(float magnitude, int time); + Vec2 world_get(Vec2 screen); + Affine view_get(Vec2 screenSize); +}; + +// A character being played, sequencing its parts each tick: the body, items, interactions, autofeed, camera and +// dialogue. class Play { public: Character* character = nullptr; Audio* audio = nullptr; - Progress progress; + Autofeed autofeed; + Body body; + Camera camera; + Interactions interactions; + Items items; Text text; - Actor actor; Actor area; Actor autoCursor; Actor cursor; - std::string idleOverride; std::vector particles; - std::vector items; + std::vector events; std::vector toasts; - std::vector dialogueCooldowns; - std::vector spawnQueue; - std::vector discovered; Rect bounds; - Vec2 autoVelocity; - Vec2 itemPointerPrevious; - Vec2 pan; - Vec2 pointerPrevious; - Vec2 shake; - float autoAngle = 0; float autosaveTime = 0; - float blinkTime = 0; float cheatTime = 0; - float talkTime = 0; - float zoom = ZOOM_LADDER[0]; - int autoClickCooldown = 0; - int autoTarget = -1; int cheatIndex = 0; int cursorType = -1; - int heldItem = -1; - int interactArea = -1; - int eatAnimation = -1; - int eatEvent = -1; - int eatItem = -1; - int shakeTime = 0; - int shakeTimeMax = 0; - int spawnDelay = 0; - int stage = 0; - int tooltipItem = -1; CursorState cursorState = CURSOR_DEFAULT; - Rect eatRect = {NAN, NAN, NAN, NAN}; - bool isAutofeed = false; - bool isAutofeedActive = false; - bool isAutoDigestHeld = false; - bool isAutoItemHeld = false; - bool isBlinking = false; bool isCheats = false; - bool isEatTextFull = false; - bool isEatTextPending = false; - bool isFocusPending = false; - bool isHoldInteracting = false; - bool isHovering = false; - bool isInteracting = false; - bool isItemHovered = false; - bool isJustItemHeld = false; - bool isJustDigested = false; - bool isJustStageFinal = false; - bool isJustStageUp = false; bool isMenuOpen = true; bool isPostgame = false; bool isPostgamePending = false; - bool isStageUp = false; - bool isTalking = false; bool isToolsOpen = false; void set(Character& played, Game game, Renderer& renderer, Audio& sound); void save(); void update(Vec2 screenSize); void render(Renderer& renderer, Vec2 screenSize); - void toast_add(StringId message); - void shake_add(float magnitude, int time); void focus(Vec2 screenSize); - Vec2 world_get(Vec2 screen); + void toast_add(const std::string& message); + void particles_spawn(const std::vector& names, Vec2 position); + Actor& pointer_actor_get(); Focus focus_get(); - float capacity_max_get(); - float capacity_percent_get(); - bool is_over_capacity(); - int stage_get(float weight); - float stage_progress_get(); - float stage_threshold_get(int index); - std::string idle_get(); - std::string suffix_get(); - void body_play(const std::string& animation, Transition transition = TRANSITION_NORMAL, - Interrupt interrupt = INTERRUPT_IF_ALLOWED); - void idle_play(); - void idle_override_set(const std::string& animation); - void values_clamp(); - - void item_add(int id, Vec2 position, Vec2 velocity = {}, float rotation = 0); - int spawn_delay_get(); - bool is_spawn_possible(); - void spawn_queue_add(int id); - void spawn_queue_add(); - void particles_spawn(const std::vector& names, Vec2 position); - Rect eat_rect_get(const EatArea& eatArea); + template void visit(Visitor&& visitor) + { + visitor("pan", camera.pan); + visitor("postgame", isPostgame); + visitor("spawn_delay", items.spawnDelay); + visitor("zoom", camera.zoom); + } private: - void body_update(); - void camera_update(Vec2 screenSize); + void area_set(); void cheats_update(); - void interact_update(Pointer pointer); - void items_update(Pointer pointer); - void autofeed_update(Pointer& itemPointer, Pointer& interactPointer); - void item_drop(Hold hold = HOLD_RELEASE); + void events_handle(Vec2 screenSize); }; diff --git a/src/renderer.h b/src/renderer.h index 86adf75..0bee9d1 100644 --- a/src/renderer.h +++ b/src/renderer.h @@ -35,6 +35,8 @@ class Renderer { public: Texture white; + bool isShowLayers = false; + bool isShowNulls = false; bool isValid = false; void init(); diff --git a/src/save.cpp b/src/save.cpp index cc82e08..f5e41e2 100644 --- a/src/save.cpp +++ b/src/save.cpp @@ -1,96 +1,13 @@ #include "save.h" #include -#include -#include #ifdef SDL_PLATFORM_EMSCRIPTEN #include #endif -constexpr const char* KEY_DISCOVERED = "discovered"; -constexpr const char* KEY_ITEM = "item"; constexpr const char* SETTINGS_FILE = "settings.txt"; -static void value_read(const char* text, float& value) -{ - value = (float)SDL_atof(text); -} - -static void value_read(const char* text, int& value) -{ - value = SDL_atoi(text); -} - -static void value_read(const char* text, bool& value) -{ - value = SDL_atoi(text); -} - -static void value_read(const char* text, Measurement& value) -{ - value = (Measurement)SDL_atoi(text); -} - -static void value_read(const char* text, std::string& value) -{ - value = text; -} - -static void value_read(const char* text, Vec2& value) -{ - SDL_sscanf(text, "%f %f", &value.x, &value.y); -} - -static void value_read(const char* text, Color& value) -{ - SDL_sscanf(text, "%f %f %f", &value.r, &value.g, &value.b); -} - -static std::string value_write(float value) -{ - return std::to_string(value); -} - -static std::string value_write(int value) -{ - return std::to_string(value); -} - -static std::string value_write(const std::string& value) -{ - return value; -} - -static std::string value_write(Vec2 value) -{ - return value_write(value.x) + " " + value_write(value.y); -} - -static std::string value_write(Color value) -{ - return value_write(value.r) + " " + value_write(value.g) + " " + value_write(value.b); -} - -// Calls line with each key and value of a key=value text file. Returns whether it was there. -template static bool lines_read(const std::string& path, Line&& line) -{ - char* text = (char*)SDL_LoadFile(path.c_str(), nullptr); - if (!text) - return false; - char* rest = nullptr; - for (char* entry = SDL_strtok_r(text, "\r\n", &rest); entry; entry = SDL_strtok_r(nullptr, "\r\n", &rest)) - { - char* value = SDL_strchr(entry, '='); - if (!value) - continue; - *value++ = '\0'; - line(std::string(entry), value); - } - SDL_free(text); - return true; -} - -static void file_write(const std::string& path, const std::string& text) +void file_write(const std::string& path, const std::string& text) { SDL_CreateDirectory(path.substr(0, path.find_last_of('/')).c_str()); if (!SDL_SaveFile(path.c_str(), text.data(), text.size())) @@ -100,48 +17,9 @@ static void file_write(const std::string& path, const std::string& text) #endif } -// Reads every field a visitable struct names from a key=value file, and writes them back. -template static bool fields_read(const std::string& path, Fields& fields) -{ - return lines_read(path, [&](const std::string& key, const char* value) { - fields.visit([&](const char* name, auto& field) { - if (key == name) - value_read(value, field); - }); - }); -} - -template static std::string fields_write(Fields& fields) -{ - std::string text; - fields.visit([&](const char* name, auto& field) { - using Type = std::decay_t; - if constexpr (std::is_same_v || std::is_enum_v) - text += std::string(name) + "=" + value_write((int)field) + "\n"; - else - text += std::string(name) + "=" + value_write(field) + "\n"; - }); - return text; -} - -template void Settings::visit(Visitor&& visitor) -{ - visitor("character_color", isCharacterColor); - visitor("color", color); - visitor("console", isConsole); - visitor("height", height); - visitor("last_character", lastCharacter); - visitor("measurement", measurement); - visitor("mute", isMuted); - visitor("volume", volume); - visitor("width", width); - visitor("x", x); - visitor("y", y); -} - void Settings::load() { - fields_read(pref_path_get(SETTINGS_FILE), *this); + is_fields_read(pref_path_get(SETTINGS_FILE), *this); } void Settings::save() @@ -159,53 +37,3 @@ void Settings::reset() isCharacterColor = defaults.isCharacterColor; isMuted = defaults.isMuted; } - -template void Progress::visit(Visitor&& visitor) -{ - visitor("alternate", isAlternate); - visitor("calories", calories); - visitor("capacity", capacity); - visitor("digesting", isDigesting); - visitor("digestion_progress", digestionProgress); - visitor("digestion_rate", digestionRate); - visitor("digestion_timer", digestionTimer); - visitor("pan", pan); - visitor("playtime", playtime); - visitor("postgame", isPostgame); - visitor("spawn_delay", spawnDelay); - visitor("total_calories", totalCalories); - visitor("total_consumed", totalConsumed); - visitor("total_spawned", totalSpawned); - visitor("weight", weight); - visitor("zoom", zoom); -} - -// Items are kept one per line as their position, velocity, rotation and then name. -void Progress::load(const std::string& path) -{ - *this = {}; - isValid = fields_read(path, *this); - lines_read(path, [&](const std::string& key, const char* value) { - if (key == KEY_DISCOVERED) - discovered.emplace_back(value); - SavedItem item; - int length = 0; - if (key == KEY_ITEM && std::sscanf(value, "%f %f %f %f %f %n", &item.position.x, &item.position.y, - &item.velocity.x, &item.velocity.y, &item.rotation, &length) == 5) - { - item.name = value + length; - items.push_back(item); - } - }); -} - -void Progress::save(const std::string& path) -{ - std::string text = fields_write(*this); - for (const std::string& name : discovered) - text += std::string(KEY_DISCOVERED) + "=" + name + "\n"; - for (const SavedItem& item : items) - text += std::string(KEY_ITEM) + "=" + value_write(item.position) + " " + value_write(item.velocity) + " " + - value_write(item.rotation) + " " + item.name + "\n"; - file_write(path, text); -} diff --git a/src/save.h b/src/save.h index b85711c..2c3306b 100644 --- a/src/save.h +++ b/src/save.h @@ -1,9 +1,14 @@ #pragma once #include "common.h" +#include #include +#include #include -#include +#include + +// Text files of key=value lines. A struct names the fields it keeps with visit(visitor), calling visitor(key, field) +// for each, and reads and writes them through fields_read and fields_write. enum Measurement { @@ -11,6 +16,76 @@ enum Measurement MEASUREMENT_IMPERIAL }; +void file_write(const std::string& path, const std::string& text); + +// Calls line with each key and value of a file. Returns whether it was there. +template bool is_file_read(const std::string& path, Line&& line) +{ + char* text = (char*)SDL_LoadFile(path.c_str(), nullptr); + if (!text) + return false; + char* rest = nullptr; + for (char* entry = SDL_strtok_r(text, "\r\n", &rest); entry; entry = SDL_strtok_r(nullptr, "\r\n", &rest)) + { + char* value = SDL_strchr(entry, '='); + if (!value) + continue; + *value++ = '\0'; + line(std::string(entry), (const char*)value); + } + SDL_free(text); + return true; +} + +template void field_read(const char* text, Field& field) +{ + if constexpr (std::is_same_v) + field = text; + else if constexpr (std::is_same_v) + std::sscanf(text, "%f %f", &field.x, &field.y); + else if constexpr (std::is_same_v) + std::sscanf(text, "%f %f %f", &field.r, &field.g, &field.b); + else if constexpr (std::is_floating_point_v) + field = (Field)SDL_atof(text); + else + field = (Field)SDL_atoi(text); +} + +template std::string field_write(const Field& field) +{ + if constexpr (std::is_same_v) + return field; + else if constexpr (std::is_same_v) + return field_write(field.x) + " " + field_write(field.y); + else if constexpr (std::is_same_v) + return field_write(field.r) + " " + field_write(field.g) + " " + field_write(field.b); + else if constexpr (std::is_floating_point_v) + return std::to_string(field); + else + return std::to_string((int)field); +} + +// Sets whichever of a struct's fields a line names. +template void field_line_read(Fields& fields, const std::string& key, const char* value) +{ + fields.visit([&](const char* name, auto& field) { + if (key == name) + field_read(value, field); + }); +} + +template bool is_fields_read(const std::string& path, Fields& fields) +{ + return is_file_read(path, [&](const std::string& key, const char* value) { field_line_read(fields, key, value); }); +} + +template std::string fields_write(Fields& fields) +{ + std::string text; + fields.visit([&](const char* name, auto& field) { text += std::string(name) + "=" + field_write(field) + "\n"; }); + return text; +} + // The game's preferences, kept in settings.txt. struct Settings { @@ -29,23 +104,26 @@ struct Settings void load(); void save(); void reset(); - template void visit(Visitor&& visitor); + + template void visit(Visitor&& visitor) + { + visitor("character_color", isCharacterColor); + visitor("color", color); + visitor("console", isConsole); + visitor("height", height); + visitor("last_character", lastCharacter); + visitor("measurement", measurement); + visitor("mute", isMuted); + visitor("volume", volume); + visitor("width", width); + visitor("x", x); + visitor("y", y); + } }; -struct SavedItem +// A character's body as it is now: what's in it, how it digests, what it's eaten. Rates are percent per tick. +struct Stats { - std::string name; - Vec2 position; - Vec2 velocity; - float rotation = 0; -}; - -// A character's progress, kept in saves/.txt, and the live state of one being played. -struct Progress -{ - std::vector discovered; - std::vector items; - Vec2 pan; float calories = 0; float capacity = 0; float digestionProgress = 0; @@ -53,17 +131,25 @@ struct Progress float playtime = 0; float totalCalories = 0; float weight = 0; - float zoom = 0; int digestionTimer = 0; - int spawnDelay = 0; int totalConsumed = 0; int totalSpawned = 0; bool isAlternate = false; bool isDigesting = false; - bool isPostgame = false; - bool isValid = false; - void load(const std::string& path); - void save(const std::string& path); - template void visit(Visitor&& visitor); + template void visit(Visitor&& visitor) + { + visitor("alternate", isAlternate); + visitor("calories", calories); + visitor("capacity", capacity); + visitor("digesting", isDigesting); + visitor("digestion_progress", digestionProgress); + visitor("digestion_rate", digestionRate); + visitor("digestion_timer", digestionTimer); + visitor("playtime", playtime); + visitor("total_calories", totalCalories); + visitor("total_consumed", totalConsumed); + visitor("total_spawned", totalSpawned); + visitor("weight", weight); + } }; diff --git a/src/strings.h b/src/strings.h deleted file mode 100644 index f9491e7..0000000 --- a/src/strings.h +++ /dev/null @@ -1,106 +0,0 @@ -#pragma once - -// Every piece of UI text a character can replace (in its character.json "strings"), with its default. -#define STRINGS(X) \ - X(MenuTabInteract, "Interact") \ - X(MenuTabInventory, "Catalog") \ - X(MenuTabSettings, "Settings") \ - X(MenuTabCheats, "Cheats") \ - X(MenuOpenTooltip, "Open Main Menu") \ - X(MenuCloseTooltip, "Close Main Menu") \ - X(InteractChatButton, "Let's chat!") \ - X(InteractHelpButton, "Help") \ - X(InteractFeelingButton, "How are you feeling?") \ - X(InteractSpawnItemButton, "Spawn Item") \ - X(InteractSpawnItemCooldownFormat, "Next item in %0.2fs") \ - X(InteractStats, "Stats") \ - X(InteractWeightFormat, "Weight: %0.2f %s (Stage: %i)") \ - X(InteractCapacityFormat, "Capacity: %0.0f / %0.0f kcal") \ - X(InteractDigestionRateFormat, "Digestion Rate: %0.2f%%/sec") \ - X(InteractTotalCaloriesFormat, "Total Calories Consumed: %0.0f kcal") \ - X(InteractTotalItemsSpawnedFormat, "Total Items Spawned: %i") \ - X(InteractTotalItemsConsumedFormat, "Total Items Consumed: %i") \ - X(InteractTotalPlaytimeFormat, "Total Playtime: %s") \ - X(SettingsWindow, "Window") \ - X(SettingsWindowMode, "Mode") \ - X(SettingsWindowModeTooltip, "Switch between windowed and fullscreen display.\n(Shortcut: F)") \ - X(SettingsWindowed, "Windowed") \ - X(SettingsFullscreen, "Fullscreen") \ - X(SettingsMeasurementSystem, "Measurement System") \ - X(SettingsMetric, "Metric") \ - X(SettingsMetricTooltip, "Use kilograms/grams (kg/g).") \ - X(SettingsImperial, "Imperial") \ - X(SettingsImperialTooltip, "Use pounds/ounces (lbs/oz).") \ - X(SettingsSound, "Sound") \ - X(SettingsVolume, "Volume") \ - X(SettingsVolumeTooltip, "Adjust master volume.") \ - X(SettingsMute, "Mute") \ - X(SettingsMuteTooltip, "Mute all audio.\n(Shortcut: M)") \ - X(SettingsAppearance, "Appearance") \ - X(SettingsUseCharacterColor, "Use Character Color") \ - X(SettingsUseCharacterColorTooltip, "When playing, the UI will use the character's preset UI color.") \ - X(SettingsColor, "Color") \ - X(SettingsColorTooltip, "Change the UI color.") \ - X(SettingsResetButton, "Reset to Default") \ - X(SettingsSaveButton, "Save") \ - X(SettingsSaveTooltip, "Save the game.\n(Note: the game autosaves frequently.)") \ - X(SettingsReturnToCharactersButton, "Return to Characters") \ - X(SettingsReturnToCharactersTooltip, "Go back to the character selection screen.\nProgress will be saved.") \ - X(ToastCheatsUnlocked, "Cheats unlocked!") \ - X(ToastSaving, "Saving...") \ - X(ToastNewItemFound, "New item found!") \ - X(ToolsHomeButton, "Home") \ - X(ToolsHomeTooltip, "Reset camera view.\n(Shortcut: Home)") \ - X(ToolsOpenTooltip, "Open Tools") \ - X(ToolsCloseTooltip, "Close Tools") \ - X(ToolsAutofeedDisabledTooltip, "Disabled while Autofeed is active!") \ - X(InventoryEmptyHint, "No items.") \ - X(InventoryFlavorFormat, "Flavor: %s") \ - X(InventoryCaloriesFormat, "%0.0f kcal") \ - X(InventoryCapacityBonusFormat, "Capacity Bonus: +%0.0f kcal") \ - X(InventoryDigestionRateBonusFormat, "Digestion Rate Bonus: +%0.2f%% / sec") \ - X(InventoryDigestionRatePenaltyFormat, "Digestion Rate Penalty: %0.2f%% / sec") \ - X(InventoryEnableAutofeedButton, "Enable Autofeed") \ - X(InventoryDisableAutofeedButton, "Disable Autofeed") \ - X(InventoryEnableAutofeedTooltip, "Serve the character food and assist in digestion automatically.") \ - X(InventoryDisableAutofeedTooltip, "Return to manual interaction.") \ - X(InfoProgressMax, "MAX") \ - X(InfoProgressToNextStage, "To Next Stage") \ - X(InfoStageProgressFormat, "Stage: %i/%i (%0.1f%%)") \ - X(InfoMaxedOut, "Maxed out!") \ - X(InfoStageStartFormat, "Start: %0.2f %s") \ - X(InfoStageCurrentFormat, "Current: %0.2f %s") \ - X(InfoStageNextFormat, "Next: %0.2f %s") \ - X(InfoDigestion, "Digestion") \ - X(InfoDigesting, "Digesting...") \ - X(InfoDigestionInProgress, "Digestion in progress...") \ - X(InfoGiveFoodToStartDigesting, "Give food to start digesting!") \ - X(InfoDigestionRateFormat, "Rate: %0.2f%% / sec") \ - X(CheatsCalories, "Calories") \ - X(CheatsCapacity, "Capacity") \ - X(CheatsWeight, "Weight") \ - X(CheatsWeightFormat, "%0.2f kg") \ - X(CheatsStage, "Stage") \ - X(CheatsDigestionRate, "Digestion Rate") \ - X(CheatsDigestionRateFormat, "%0.2f%% / sec") \ - X(CheatsDigestButton, "Digest") - -enum StringId -{ -#define X(name, text) STRING_##name, - STRINGS(X) -#undef X - STRING_COUNT -}; - -constexpr const char* STRING_NAMES[] = { -#define X(name, text) #name, - STRINGS(X) -#undef X -}; - -constexpr const char* STRING_DEFAULTS[] = { -#define X(name, text) text, - STRINGS(X) -#undef X -}; diff --git a/src/ui.cpp b/src/ui.cpp index d839391..1440c34 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1,9 +1,10 @@ #include "ui.h" #include "play.h" #include "renderer.h" -#include "save.h" +#include #include #include +#include constexpr ImGuiWindowFlags PANEL_FLAGS = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; @@ -19,60 +20,41 @@ constexpr float COLOR_MIN = 0.1f; constexpr float FX_FREQUENCY = 10; constexpr float FX_THICKNESS = 2; constexpr float FX_THICKNESS_WAVE = 3; +constexpr float INDICATOR_BOB = 0.12f; +constexpr float INDICATOR_SIZE = 0.35f; +constexpr float INDICATOR_SPEED = 2; constexpr float ITEM_SIZE = 96; constexpr float KG_TO_G = 1000; constexpr float KG_TO_LB = 2.20462f; constexpr float LB_TO_OZ = 16; constexpr float MENU_WIDTH = 0.3f; +constexpr float OSCILLATION_AMPLITUDE = 2; +constexpr float OSCILLATION_FREQUENCY = 1; +constexpr float OSCILLATION_SPREAD = 0.16f; constexpr float SCROLLBAR_SIZE = 30; -constexpr float SLIDE_TIME = 0.125f; +constexpr float SHAKE_AMPLITUDE = 1; +constexpr float SHAKE_FREQUENCY = 30; constexpr float SLIDE_EASE = 2; +constexpr float SLIDE_TIME = 0.125f; +constexpr float TEXT_BIG = 1.25f; constexpr float TEXT_FIT_MIN = 1; +constexpr float TEXT_HEIGHT_LINES = 6; +constexpr float TEXT_SMALL = 0.75f; +constexpr float TEXT_WIDTH = 0.3f; constexpr float TOOLS_WIDTH = 0.05f; constexpr float TOOLTIP_WRAP = 24; constexpr float TRIANGLE_SIZE = 0.22f; constexpr float UNDISCOVERED_ALPHA = 0.5f; -constexpr int SECONDS_PER_MINUTE = 60; +constexpr int PALETTE_SIZE = 25; constexpr int SECONDS_PER_HOUR = 3600; +constexpr int SECONDS_PER_MINUTE = 60; constexpr int TEXT_SIZE = 512; constexpr const char* UNKNOWN = "???"; -static Audio* widgetAudio = nullptr; -static const Sounds* widgetHover = nullptr; -static const Sounds* widgetSelect = nullptr; +using Palette = std::array, PALETTE_SIZE>; -void widget_sounds_set(Audio* audio, const Sounds* hover, const Sounds* select) -{ - widgetAudio = audio; - widgetHover = hover; - widgetSelect = select; -} - -void widget_fx() -{ - ImGuiStorage* storage = ImGui::GetStateStorage(); - ImGuiID id = ImGui::GetItemID(); - if (ImGui::IsItemActivated() && widgetAudio && widgetSelect) - widgetAudio->play(*widgetSelect); - if (!ImGui::IsItemHovered()) - { - if (id) - storage->SetBool(id, false); - return; - } - if (id && !storage->GetBool(id) && widgetAudio && widgetHover) - widgetAudio->play(*widgetHover); - if (id) - storage->SetBool(id, true); - float wave = std::sin((float)ImGui::GetTime() * FX_FREQUENCY); - ImVec4 border = ImGui::GetStyleColorVec4(ImGuiCol_CheckMark); - border.w = 1 + wave; - ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), ImGui::GetColorU32(border), - ImGui::GetStyle().FrameRounding, 0, FX_THICKNESS + FX_THICKNESS_WAVE * wave); -} - -// Colors every widget from one color: darker backgrounds, lighter hovers, brighter accents. -void style_set(Color color, float rounding) +// Every widget color from one color: darker backgrounds, lighter hovers, brighter accents. +static Palette palette_get(Color color) { auto shade = [&](float amount, float alpha = 1) { return ImVec4(color.r * amount, color.g * amount, color.b * amount, alpha); @@ -82,39 +64,78 @@ void style_set(Color color, float rounding) std::clamp(color.g * amount, COLOR_MIN, COLOR_MAX), std::clamp(color.b * amount, COLOR_MIN, COLOR_MAX), 1); }; + return {{{ImGuiCol_Button, shade(1)}, + {ImGuiCol_FrameBg, shade(1)}, + {ImGuiCol_Header, shade(1)}, + {ImGuiCol_Tab, shade(1)}, + {ImGuiCol_TitleBg, shade(1)}, + {ImGuiCol_TitleBgCollapsed, shade(1)}, + {ImGuiCol_TitleBgActive, shade(1)}, + {ImGuiCol_ScrollbarBg, shade(1)}, + {ImGuiCol_FrameBgHovered, bright(COLOR_HOVERED)}, + {ImGuiCol_HeaderHovered, bright(COLOR_HOVERED)}, + {ImGuiCol_ButtonHovered, bright(COLOR_HOVERED)}, + {ImGuiCol_TabHovered, bright(COLOR_HOVERED)}, + {ImGuiCol_ScrollbarGrab, bright(COLOR_HOVERED)}, + {ImGuiCol_ScrollbarGrabHovered, bright(COLOR_HOVERED)}, + {ImGuiCol_FrameBgActive, bright(COLOR_ACTIVE)}, + {ImGuiCol_HeaderActive, bright(COLOR_ACTIVE)}, + {ImGuiCol_ButtonActive, bright(COLOR_ACTIVE)}, + {ImGuiCol_TabSelected, bright(COLOR_ACTIVE)}, + {ImGuiCol_ScrollbarGrabActive, shade(COLOR_ACCENT)}, + {ImGuiCol_PlotHistogram, shade(COLOR_ACCENT)}, + {ImGuiCol_CheckMark, shade(COLOR_ACCENT)}, + {ImGuiCol_SliderGrab, shade(COLOR_ACCENT)}, + {ImGuiCol_SliderGrabActive, shade(COLOR_ACCENT_ACTIVE)}, + {ImGuiCol_WindowBg, shade(COLOR_BACKGROUND, COLOR_BACKGROUND_ALPHA)}, + {ImGuiCol_ChildBg, shade(COLOR_BACKGROUND, 0)}}}; +} + +void style_set(Color color, float rounding) +{ ImGuiStyle& style = ImGui::GetStyle(); style.WindowRounding = style.FrameRounding = style.GrabRounding = rounding; style.ScrollbarSize = SCROLLBAR_SIZE; - ImVec4* colors = style.Colors; - for (ImGuiCol id : {ImGuiCol_Button, ImGuiCol_FrameBg, ImGuiCol_Header, ImGuiCol_Tab, ImGuiCol_TitleBg, - ImGuiCol_TitleBgCollapsed, ImGuiCol_TitleBgActive, ImGuiCol_ScrollbarBg}) - colors[id] = shade(1); - for (ImGuiCol id : {ImGuiCol_FrameBgHovered, ImGuiCol_HeaderHovered, ImGuiCol_ButtonHovered, ImGuiCol_TabHovered, - ImGuiCol_ScrollbarGrab, ImGuiCol_ScrollbarGrabHovered}) - colors[id] = bright(COLOR_HOVERED); - for (ImGuiCol id : {ImGuiCol_FrameBgActive, ImGuiCol_HeaderActive, ImGuiCol_ButtonActive, ImGuiCol_TabSelected}) - colors[id] = bright(COLOR_ACTIVE); - for (ImGuiCol id : {ImGuiCol_ScrollbarGrabActive, ImGuiCol_PlotHistogram, ImGuiCol_CheckMark, - ImGuiCol_SliderGrab}) - colors[id] = shade(COLOR_ACCENT); - colors[ImGuiCol_SliderGrabActive] = shade(COLOR_ACCENT_ACTIVE); - colors[ImGuiCol_WindowBg] = colors[ImGuiCol_PopupBg] = shade(COLOR_BACKGROUND, COLOR_BACKGROUND_ALPHA); - colors[ImGuiCol_ChildBg] = shade(COLOR_BACKGROUND, 0); + for (auto [id, value] : palette_get(color)) + style.Colors[id] = value; + style.Colors[ImGuiCol_PopupBg] = style.Colors[ImGuiCol_WindowBg]; } -float row_width_get(int count) +// Colors widgets from one color until style_pop. +static void style_push(Color color) +{ + for (auto [id, value] : palette_get(color)) + ImGui::PushStyleColor(id, value); +} + +static void style_pop() +{ + ImGui::PopStyleColor(PALETTE_SIZE); +} + +static ImVec4 imvec4_get(Color color) +{ + return {color.r, color.g, color.b, color.a}; +} + +static ImTextureID texture_id_get(const Texture& texture) +{ + return (ImTextureID)(intptr_t)texture.id; +} + +static float row_width_get(int count) { return (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * (count - 1)) / count; } -void tooltip_set(const char* text, float size) +static void tooltip_set(const char* text) { - ImGui::PushFont(nullptr, size); + ImGui::PushFont(nullptr, FONT_NORMAL); ImGui::SetItemTooltip("%s", text); ImGui::PopFont(); } -void triangle_draw(ImVec2 position, ImVec2 size, Direction direction) +static void triangle_draw(ImVec2 position, ImVec2 size, Direction direction) { ImVec2 center = {position.x + size.x / 2, position.y + size.y / 2}; float half = size.x * TRIANGLE_SIZE; @@ -125,23 +146,23 @@ void triangle_draw(ImVec2 position, ImVec2 size, Direction direction) ImGui::GetColorU32(ImGuiCol_Text)); } -// Text through a printf format, for formats that come from character strings. -template static std::string text_format(const std::string& format, Args... args) +template static std::string text_format(const char* format, Args... args) { char text[TEXT_SIZE]; - std::snprintf(text, sizeof(text), format.c_str(), args...); + std::snprintf(text, sizeof(text), format, args...); return text; } -// A weight in the chosen units, as text with thousands separators, dropping to grams or ounces below one. +// A weight in the chosen units, with thousands separators, dropping to grams or ounces below one. static std::string weight_format(float kilograms, Measurement measurement) { - float value = measurement == MEASUREMENT_IMPERIAL ? kilograms * KG_TO_LB : kilograms; - const char* unit = measurement == MEASUREMENT_IMPERIAL ? "lbs" : "kg"; + bool isImperial = measurement == MEASUREMENT_IMPERIAL; + float value = isImperial ? kilograms * KG_TO_LB : kilograms; + const char* unit = isImperial ? "lbs" : "kg"; if (value < 1) { - value *= measurement == MEASUREMENT_IMPERIAL ? LB_TO_OZ : KG_TO_G; - unit = measurement == MEASUREMENT_IMPERIAL ? "oz" : "g"; + value *= isImperial ? LB_TO_OZ : KG_TO_G; + unit = isImperial ? "oz" : "g"; } std::string digits = text_format("%.2f", value); for (int i = (int)digits.find('.') - 3; i > 0; i -= 3) @@ -149,13 +170,6 @@ static std::string weight_format(float kilograms, Measurement measurement) return digits + " " + unit; } -static std::string weight_number_format(float kilograms, Measurement measurement, const char** unit) -{ - std::string text = weight_format(kilograms, measurement); - *unit = text.ends_with("lbs") ? "lbs" : text.ends_with("oz") ? "oz" : text.ends_with(" g") ? "g" : "kg"; - return text; -} - static std::string playtime_format(float seconds) { int total = std::max(0, (int)seconds); @@ -196,30 +210,126 @@ static ImVec2 canvas_draw(Renderer& renderer, Canvas& canvas, Actor& actor, ImVe return image; } -static ImTextureID texture_id_get(const Texture& texture) +static float hash_unit(Uint32 value) { - return (ImTextureID)(intptr_t)texture.id; + value ^= value >> 16; + value *= 0x7feb352du; + value ^= value >> 15; + value *= 0x846ca68bu; + value ^= value >> 16; + return (value & 0xffffu) / 65535.0f; +} + +// The scale, color and offset a dialogue entry's effects give its character at an index. +static float letter_style_get(const Entry& entry, int at, float time, ImU32& color, ImVec2& offset) +{ + float scale = 1; + color = ImGui::GetColorU32(ImGuiCol_Text); + offset = {}; + for (const Effect& effect : entry.effects) + { + if (at < effect.start || at >= effect.end) + continue; + Uint32 seed = (Uint32)std::floor(time * SHAKE_FREQUENCY) * 747796405u + (Uint32)at * 2891336453u; + if (effect.type == EFFECT_COLOR) + color = ImGui::GetColorU32(imvec4_get({effect.color.r, effect.color.g, effect.color.b, 1})); + else if (effect.type == EFFECT_BIG || effect.type == EFFECT_SMALL) + scale = effect.type == EFFECT_BIG ? TEXT_BIG : TEXT_SMALL; + else if (effect.type == EFFECT_OSCILLATION) + offset.y += + std::sin((time * OSCILLATION_FREQUENCY + at * OSCILLATION_SPREAD) * 2 * PI) * OSCILLATION_AMPLITUDE; + else if (effect.type == EFFECT_SHAKE) + { + offset.x += (hash_unit(seed) * 2 - 1) * SHAKE_AMPLITUDE; + offset.y += (hash_unit(seed ^ 0xa511e9b3u) * 2 - 1) * SHAKE_AMPLITUDE; + } + } + return scale; +} + +// Draws a dialogue entry's first count characters with their effects, wrapping whole words to the window. +static void letters_draw(const Entry& entry, int count, float time) +{ + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 start = ImGui::GetCursorScreenPos(); + float wrap = start.x + ImGui::GetContentRegionAvail().x; + ImVec2 cursor = start; + float lineHeight = fontSize; + const char* end = entry.text.data() + entry.text.size(); + auto width_get = [&](const char* from, const char* to, int at) { + ImU32 color; + ImVec2 offset; + return font->CalcTextSizeA(fontSize * letter_style_get(entry, at, time, color, offset), FLT_MAX, 0, from, to).x; + }; + auto line_break = [&]() { + cursor = {start.x, cursor.y + lineHeight}; + lineHeight = fontSize; + }; + + int at = 0; + for (const char* it = entry.text.data(); it < end && at < count; at++) + { + unsigned int codepoint = 0; + const char* next = it + ImTextCharFromUtf8(&codepoint, it, end); + if (codepoint == '\n') + { + line_break(); + it = next; + continue; + } + bool isSpace = codepoint == ' '; + if (!isSpace && cursor.x > start.x && it[-1] == ' ') + { + float wordWidth = 0; + int wordAt = at; + for (const char* letter = it; letter < end && *letter != ' ' && *letter != '\n'; wordAt++) + { + const char* letterNext = letter + ImTextCharFromUtf8(&codepoint, letter, end); + wordWidth += width_get(letter, letterNext, wordAt); + letter = letterNext; + } + if (cursor.x + wordWidth > wrap && wordWidth <= wrap - start.x) + line_break(); + } + ImU32 color; + ImVec2 offset; + float scale = letter_style_get(entry, at, time, color, offset); + float width = width_get(it, next, at); + if (cursor.x + width > wrap) + { + line_break(); + if (isSpace) + { + it = next; + continue; + } + } + ImGui::GetWindowDrawList()->AddText(font, fontSize * scale, {cursor.x + offset.x, cursor.y + offset.y}, color, + it, next); + cursor.x += width; + lineHeight = std::max(lineHeight, fontSize * scale); + it = next; + } + ImGui::Dummy({wrap - start.x, cursor.y + lineHeight - start.y}); } static void item_summary_draw(Character& character, const Item& item) { ImGui::PushStyleColor(ImGuiCol_Text, imvec4_get(GRAY)); - std::string rarity = item.rarity >= 0 ? " (" + character.rarities[item.rarity].name + ")" : ""; + std::string rarity = item.rarity >= 0 ? " (" + character.catalog.rarities[item.rarity].name + ")" : ""; if (!item.category.empty()) ImGui::TextWrapped("-- %s%s --", item.category.c_str(), rarity.c_str()); if (!item.flavor.empty()) - ImGui::TextWrapped("%s", text_format(character.strings[STRING_InventoryFlavorFormat], item.flavor.c_str()).c_str()); + ImGui::TextWrapped("Flavor: %s", item.flavor.c_str()); if (item.calories != 0) - ImGui::TextWrapped("%s", text_format(character.strings[STRING_InventoryCaloriesFormat], item.calories).c_str()); + ImGui::TextWrapped("%0.0f kcal", item.calories); if (item.capacityBonus != 0) - ImGui::TextWrapped("%s", - text_format(character.strings[STRING_InventoryCapacityBonusFormat], item.capacityBonus).c_str()); - if (item.digestionBonus != 0) - { - StringId format = item.digestionBonus > 0 ? STRING_InventoryDigestionRateBonusFormat - : STRING_InventoryDigestionRatePenaltyFormat; - ImGui::TextWrapped("%s", text_format(character.strings[format], item.digestionBonus * TICK_RATE).c_str()); - } + ImGui::TextWrapped("Capacity Bonus: +%0.0f kcal", item.capacityBonus); + if (item.digestionBonus > 0) + ImGui::TextWrapped("Digestion Rate Bonus: +%0.2f%% / sec", item.digestionBonus * TICK_RATE); + if (item.digestionBonus < 0) + ImGui::TextWrapped("Digestion Rate Penalty: %0.2f%% / sec", item.digestionBonus * TICK_RATE); ImGui::PopStyleColor(); } @@ -240,11 +350,47 @@ static void item_details_draw(Character& character, const Item* item) ImGui::PopTextWrapPos(); } -// A panel sliding in from a screen edge, with the bar that opens and closes it. Hovering the bar opens it too. -static void panel_bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction, - StringId openTooltip, StringId closeTooltip) +static void slide_update(float& slide, bool isOpen) +{ + float step = TICK / SLIDE_TIME; + slide = std::clamp(slide + (isOpen ? step : -step), 0.0f, 1.0f); +} + +// Passes a widget's result through after giving it the character's hover and select sounds and a pulsing border. +bool Ui::is_fx(bool result) +{ + ImGuiStorage* storage = ImGui::GetStateStorage(); + ImGuiID id = ImGui::GetItemID(); + const Sounds* hover = character ? &character->sounds[SOUND_HOVER] : nullptr; + if (ImGui::IsItemActivated() && character) + audio->play(character->sounds[SOUND_SELECT]); + if (!ImGui::IsItemHovered()) + { + if (id) + storage->SetBool(id, false); + return result; + } + if (id && !storage->GetBool(id) && hover) + audio->play(*hover); + if (id) + storage->SetBool(id, true); + float wave = std::sin((float)ImGui::GetTime() * FX_FREQUENCY); + ImVec4 border = ImGui::GetStyleColorVec4(ImGuiCol_CheckMark); + border.w = 1 + wave; + ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), ImGui::GetColorU32(border), + ImGui::GetStyle().FrameRounding, 0, FX_THICKNESS + FX_THICKNESS_WAVE * wave); + return result; +} + +void Ui::command_add(CommandType type, const std::string& key) +{ + commands->push_back({key, type}); +} + +// A side panel's bar, which opens and closes it. Hovering the bar opens it too. +void Ui::bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction, + const char* openTooltip, const char* closeTooltip) { - Character& character = *play.character; ImGui::SetNextWindowSize(size); ImGui::SetNextWindowPos(position); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0); @@ -258,74 +404,221 @@ static void panel_bar_draw(Play& play, bool& isOpen, float slide, ImVec2 positio bool isPressed = is_fx(ImGui::Button("##Toggle", buttonSize)); if (slide <= 0 || slide >= 1) { - tooltip_set(character.strings[isOpen ? closeTooltip : openTooltip].c_str()); - bool isHoverOpen = !isOpen && !isPressed && slide <= 0 && ImGui::IsItemHovered(); - if (isPressed || isHoverOpen) + tooltip_set(isOpen ? closeTooltip : openTooltip); + if (isPressed || (!isOpen && slide <= 0 && ImGui::IsItemHovered())) { isOpen = !isOpen; - play.audio->play(character.sounds[isOpen ? SOUND_OPEN : SOUND_CLOSE]); + play.audio->play(play.character->sounds[isOpen ? SOUND_OPEN : SOUND_CLOSE]); } } - bool isPointingLeft = (direction == DIRECTION_LEFT) != isOpen; - triangle_draw(cursor, buttonSize, isPointingLeft ? DIRECTION_LEFT : DIRECTION_RIGHT); + triangle_draw(cursor, buttonSize, (direction == DIRECTION_LEFT) != isOpen ? DIRECTION_LEFT : DIRECTION_RIGHT); } ImGui::End(); ImGui::PopStyleVar(4); } -static void slide_update(float& slide, bool isOpen) +void Ui::text_draw(Play& play, ImVec2 screen) { - float step = ImGui::GetIO().DeltaTime / SLIDE_TIME; - slide = std::clamp(slide + (isOpen ? step : -step), 0.0f, 1.0f); + Text& text = play.text; + Entry* entry = text.entry; + if (!entry || entry->text.empty()) + return; + ImGuiStyle& style = ImGui::GetStyle(); + ImVec2 size = {screen.x * TEXT_WIDTH - style.WindowPadding.x * 2, + ImGui::GetTextLineHeightWithSpacing() * TEXT_HEIGHT_LINES}; + ImGui::SetNextWindowSize(size); + ImGui::SetNextWindowPos({screen.x / 2 - size.x / 2, screen.y - size.y - style.WindowPadding.y}); + if (ImGui::Begin("##Text", nullptr, PANEL_FLAGS)) + { + bool isHovered = ImGui::IsWindowHovered(); + bool isSpace = ImGui::IsKeyReleased(ImGuiKey_Space); + bool isAdvance = (isHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) || isSpace; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {style.ItemSpacing.x, 0}); + if (ImGui::BeginTabBar("##Name")) + { + if (ImGui::BeginTabItem(play.character->name.c_str())) + ImGui::EndTabItem(); + ImGui::EndTabBar(); + } + ImGui::PushFont(nullptr, FONT_NORMAL); + letters_draw(*entry, text.index, (float)ImGui::GetTime()); + + ImVec2 position = ImGui::GetWindowPos(); + ImVec2 contentMin = {position.x + style.WindowPadding.x, position.y + style.WindowPadding.y}; + ImVec2 contentMax = {position.x + ImGui::GetWindowWidth() - style.WindowPadding.x, + position.y + ImGui::GetWindowHeight() - style.WindowPadding.y}; + if (text.isFinished && !entry->choices.empty()) + { + float buttonY = contentMax.y - ImGui::GetFrameHeight(); + float separatorY = buttonY - style.ItemSpacing.y; + ImGui::GetWindowDrawList()->AddLine({contentMin.x, separatorY}, {contentMax.x, separatorY}, + ImGui::GetColorU32(ImGuiCol_Separator)); + ImGui::SetCursorScreenPos({contentMin.x, buttonY}); + ImVec2 buttonSize = {row_width_get((int)entry->choices.size()), 0}; + int chosen = isHovered && isSpace ? 0 : -1; + for (int i = 0; i < (int)entry->choices.size(); i++) + { + ImGui::PushID(i); + if (is_fx(ImGui::Button(entry->choices[i].text.c_str(), buttonSize))) + chosen = i; + ImGui::SetItemTooltip("%s", entry->choices[i].text.c_str()); + ImGui::PopID(); + if (i + 1 < (int)entry->choices.size()) + ImGui::SameLine(); + } + if (chosen == 0 && isSpace) + text.advance(play); + else if (chosen >= 0) + text.choose(play, chosen); + } + else + { + if (text.isFinished && !entry->next.empty()) + { + float side = ImGui::GetTextLineHeightWithSpacing(); + ImVec2 center = {contentMax.x - side / 2 + + std::sin((float)ImGui::GetTime() * INDICATOR_SPEED) * side * INDICATOR_BOB, + contentMax.y - side / 2}; + float half = side * INDICATOR_SIZE; + ImGui::GetWindowDrawList()->AddTriangleFilled({center.x + half, center.y}, + {center.x - half, center.y - half}, + {center.x - half, center.y + half}, + ImGui::GetColorU32(ImGuiCol_Text)); + } + if (isAdvance) + text.advance(play); + } + ImGui::PopFont(); + ImGui::PopStyleVar(); + } + ImGui::End(); } -static void interact_tab_draw(Play& play, Settings& settings) +void Ui::toasts_draw(Play& play) { - Character& character = *play.character; - Progress& progress = play.progress; - std::array& strings = character.strings; ImGuiStyle& style = ImGui::GetStyle(); - Dialogue& dialogue = character.dialogue; - std::string feelingPool = play.stage <= 0 ? character.dialoguePool - : character.stages[std::min(play.stage, (int)character.stages.size()) - 1].dialoguePool; - bool isRandom = dialogue.hooks.contains("Random"); - bool isHelp = dialogue.hooks.contains("Help"); + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + float height = ImGui::GetTextLineHeightWithSpacing() + style.WindowPadding.y * 2; + for (int i = 0; i < (int)play.toasts.size(); i++) + { + Toast& toast = play.toasts[i]; + auto color_get = [&](ImGuiCol id) { + ImVec4 color = ImGui::GetStyleColorVec4(id); + color.w = (float)toast.life / TOAST_LIFE; + return ImGui::GetColorU32(color); + }; + ImVec2 position = {viewport->WorkPos.x + style.WindowPadding.x, + viewport->WorkPos.y + viewport->WorkSize.y - style.WindowPadding.y - + height * (play.toasts.size() - i)}; + ImVec2 end = {position.x + ImGui::CalcTextSize(toast.message.c_str()).x + style.WindowPadding.x * 2, + position.y + height}; + drawList->AddRectFilled(position, end, color_get(ImGuiCol_WindowBg), style.WindowRounding); + drawList->AddRect(position, end, color_get(ImGuiCol_Border), style.WindowRounding); + drawList->AddText({position.x + style.WindowPadding.x, position.y + style.WindowPadding.y}, + color_get(ImGuiCol_Text), toast.message.c_str()); + } +} + +void Ui::settings_draw(Settings& settings) +{ + ImGui::SeparatorText("Measurement System"); + is_fx(ImGui::RadioButton("Metric", (int*)&settings.measurement, MEASUREMENT_METRIC)); + ImGui::SetItemTooltip("Use kilograms/grams (kg/g)."); + ImGui::SameLine(); + is_fx(ImGui::RadioButton("Imperial", (int*)&settings.measurement, MEASUREMENT_IMPERIAL)); + ImGui::SetItemTooltip("Use pounds/ounces (lbs/oz)."); + + ImGui::SeparatorText("Window"); + const char* modes[] = {"Windowed", "Fullscreen"}; + int mode = isFullscreen; + if (is_fx(ImGui::Combo("Mode", &mode, modes, (int)std::size(modes)))) + command_add(COMMAND_FULLSCREEN); + ImGui::SetItemTooltip("Switch between windowed and fullscreen display.\n(Shortcut: F)"); + + ImGui::SeparatorText("Sound"); + ImGui::BeginDisabled(settings.isMuted); + if (is_fx(ImGui::SliderInt("Volume", &settings.volume, 0, (int)PERCENT, "%d%%"))) + audio->gain_set(settings.volume / PERCENT); + ImGui::SetItemTooltip("Adjust master volume."); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (is_fx(ImGui::Checkbox("Mute", &settings.isMuted))) + audio->gain_set(settings.isMuted ? 0 : settings.volume / PERCENT); + ImGui::SetItemTooltip("Mute all audio.\n(Shortcut: M)"); + + ImGui::SeparatorText("Appearance"); + bool isColorChanged = is_fx(ImGui::Checkbox("Use Character Color", &settings.isCharacterColor)); + ImGui::SetItemTooltip("When playing, the UI will use the character's preset UI color."); + ImGui::SameLine(); + ImGui::BeginDisabled(settings.isCharacterColor); + isColorChanged |= is_fx(ImGui::ColorEdit3("Color", &settings.color.r, + ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoTooltip)); + ImGui::SetItemTooltip("Change the UI color."); + ImGui::EndDisabled(); + ImGui::Separator(); + if (is_fx(ImGui::Button("Reset to Default", {-FLT_MIN, 0}))) + { + settings.reset(); + audio->gain_set(settings.volume / PERCENT); + isColorChanged = true; + } + if (isColorChanged) + style_set(character && settings.isCharacterColor ? character->color : settings.color, + character ? character->menu.rounding : 0); + if (!character) + return; + ImGui::Separator(); + if (is_fx(ImGui::Button("Save", {-FLT_MIN, 0}))) + command_add(COMMAND_SAVE); + ImGui::SetItemTooltip("Save the game.\n(Note: the game autosaves frequently.)"); + if (is_fx(ImGui::Button("Return to Characters", {-FLT_MIN, 0}))) + command_add(COMMAND_MENU); + ImGui::SetItemTooltip("Go back to the character selection screen.\nProgress will be saved."); +} + +void Ui::interact_tab_draw(Play& play, Settings& settings) +{ + Character& data = *play.character; + Body& body = play.body; + Stats& stats = body.stats; + Dialogue& dialogue = data.dialogue; + ImGuiStyle& style = ImGui::GetStyle(); + std::string feelingPool = body.stage <= 0 + ? data.dialoguePool + : data.stages[std::min(body.stage, (int)data.stages.size()) - 1].dialoguePool; + bool isRandom = dialogue.is_hook(HOOK_RANDOM); + bool isHelp = dialogue.is_hook(HOOK_HELP); bool isFeeling = dialogue.pools.contains(feelingPool); ImGui::PushFont(nullptr, FONT_HUGE); - ImGui::SeparatorText(character.name.c_str()); + ImGui::SeparatorText(data.name.c_str()); ImGui::PopFont(); ImVec2 childSize = {(ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2, FONT_HEADER_2 + ImGui::GetFrameHeightWithSpacing()}; if (ImGui::BeginChild("##Weight", childSize)) { - std::string weight = weight_format(progress.weight, settings.measurement); + std::string weight = weight_format(stats.weight, settings.measurement); text_fit_draw(weight, {ImGui::GetContentRegionAvail().x, FONT_HEADER_2}, FONT_HEADER_2); ImGui::SetItemTooltip("%s", weight.c_str()); - bool isMax = play.stage >= (int)character.stages.size(); - ImGui::ProgressBar(play.stage_progress_get(), {ImGui::GetContentRegionAvail().x, 0}, - strings[isMax ? STRING_InfoProgressMax : STRING_InfoProgressToNextStage].c_str()); + bool isMax = body.stage >= (int)data.stages.size(); + ImGui::ProgressBar(body.stage_progress_get(), {ImGui::GetContentRegionAvail().x, 0}, + isMax ? "MAX" : "To Next Stage"); if (ImGui::BeginItemTooltip()) { - ImGui::Text(strings[STRING_InfoStageProgressFormat].c_str(), play.stage + 1, - (int)character.stages.size() + 1, play.stage_progress_get() * PERCENT); + ImGui::Text("Stage: %i/%i (%0.1f%%)", body.stage + 1, (int)data.stages.size() + 1, + body.stage_progress_get() * PERCENT); ImGui::Separator(); ImGui::PushStyleColor(ImGuiCol_Text, imvec4_get(GRAY)); if (isMax) - ImGui::TextUnformatted(strings[STRING_InfoMaxedOut].c_str()); + ImGui::TextUnformatted("Maxed out!"); else { - const char* unit = ""; - StringId formats[] = {STRING_InfoStageStartFormat, STRING_InfoStageCurrentFormat, - STRING_InfoStageNextFormat}; - float weights[] = {play.stage_threshold_get(play.stage), progress.weight, - play.stage_threshold_get(play.stage + 1)}; - for (int i = 0; i < 3; i++) - { - std::string number = weight_number_format(weights[i], settings.measurement, &unit); - ImGui::Text(strings[formats[i]].c_str(), (float)SDL_atof(number.c_str()), unit); - } + ImGui::Text("Start: %s", weight_format(body.stage_threshold_get(body.stage), settings.measurement).c_str()); + ImGui::Text("Current: %s", weight.c_str()); + ImGui::Text("Next: %s", + weight_format(body.stage_threshold_get(body.stage + 1), settings.measurement).c_str()); } ImGui::PopStyleColor(); ImGui::EndTooltip(); @@ -335,48 +628,47 @@ static void interact_tab_draw(Play& play, Settings& settings) ImGui::SameLine(); if (ImGui::BeginChild("##Calories", childSize)) { - float capacityMax = play.capacity_max_get(); - float overstuffed = std::max(0.0f, (progress.calories - progress.capacity) / (capacityMax - progress.capacity)); + float capacityMax = body.capacity_max_get(); + float overstuffed = std::max(0.0f, (stats.calories - stats.capacity) / (capacityMax - stats.capacity)); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1 - overstuffed, 1 - overstuffed, 1)); - text_fit_draw(text_format("%.0f / %.0f kcal", progress.calories, progress.capacity), + text_fit_draw(text_format("%.0f / %.0f kcal", stats.calories, stats.capacity), {ImGui::GetContentRegionAvail().x, FONT_HEADER_2}, FONT_HEADER_2); ImGui::PopStyleColor(); if (ImGui::BeginItemTooltip()) { - ImGui::Text("%.0f / %.0f ", progress.calories, progress.capacity); + ImGui::Text("%.0f / %.0f ", stats.calories, stats.capacity); ImGui::SameLine(0, 0); ImGui::TextColored(imvec4_get(GRAY), "(%.0f)", capacityMax); ImGui::SameLine(0, 0); ImGui::TextUnformatted(" kcal"); ImGui::EndTooltip(); } - float digestion = progress.isDigesting ? (float)progress.digestionTimer / std::max(1, character.digestionTime) - : progress.digestionProgress / DIGESTION_MAX; + float digestion = stats.isDigesting ? (float)stats.digestionTimer / std::max(1, data.traits.digestionTime) + : stats.digestionProgress / DIGESTION_MAX; ImGui::ProgressBar(digestion, {ImGui::GetContentRegionAvail().x, 0}, - strings[progress.isDigesting ? STRING_InfoDigesting : STRING_InfoDigestion].c_str()); + stats.isDigesting ? "Digesting..." : "Digestion"); if (ImGui::BeginItemTooltip()) { - if (progress.isDigesting) - ImGui::TextUnformatted(strings[STRING_InfoDigestionInProgress].c_str()); + if (stats.isDigesting) + ImGui::TextUnformatted("Digestion in progress..."); else if (digestion <= 0) - ImGui::TextUnformatted(strings[STRING_InfoGiveFoodToStartDigesting].c_str()); + ImGui::TextUnformatted("Give food to start digesting!"); else ImGui::Text("%0.2f%%", digestion * PERCENT); ImGui::Separator(); - ImGui::TextColored(imvec4_get(GRAY), strings[STRING_InfoDigestionRateFormat].c_str(), - progress.digestionRate * TICK_RATE); + ImGui::TextColored(imvec4_get(GRAY), "Rate: %0.2f%% / sec", stats.digestionRate * TICK_RATE); ImGui::EndTooltip(); } } ImGui::EndChild(); ImGui::PushFont(nullptr, FONT_HEADER_2); - ImGui::SeparatorText(strings[STRING_InteractStats].c_str()); + ImGui::SeparatorText("Stats"); ImGui::PopFont(); - ImGui::Text(strings[STRING_InteractTotalCaloriesFormat].c_str(), progress.totalCalories); - ImGui::Text(strings[STRING_InteractTotalItemsSpawnedFormat].c_str(), progress.totalSpawned); - ImGui::Text(strings[STRING_InteractTotalItemsConsumedFormat].c_str(), progress.totalConsumed); - ImGui::Text(strings[STRING_InteractTotalPlaytimeFormat].c_str(), playtime_format(progress.playtime).c_str()); + ImGui::Text("Total Calories Consumed: %0.0f kcal", stats.totalCalories); + ImGui::Text("Total Items Spawned: %i", stats.totalSpawned); + ImGui::Text("Total Items Consumed: %i", stats.totalConsumed); + ImGui::Text("Total Playtime: %s", playtime_format(stats.playtime).c_str()); auto frame_height_get = [](float size) { ImGui::PushFont(nullptr, size); @@ -392,68 +684,69 @@ static void interact_tab_draw(Play& play, Settings& settings) ImGui::Dummy({ImGui::GetContentRegionAvail().x, filler}); float width = ImGui::GetContentRegionAvail().x; + Items& items = play.items; ImGui::PushFont(nullptr, FONT_HUGE); - if (play.spawnDelay <= 0) + if (items.spawnDelay <= 0) { - ImGui::BeginDisabled(!play.is_spawn_possible()); - if (is_fx(ImGui::Button(strings[STRING_InteractSpawnItemButton].c_str(), {width, 0}))) - play.spawn_queue_add(); + ImGui::BeginDisabled(!items.is_spawn_possible(play)); + if (is_fx(ImGui::Button("Spawn Item", {width, 0}))) + items.spawn_queue_add(play); ImGui::EndDisabled(); } else { ImGui::BeginDisabled(); - ImGui::ProgressBar(1 - std::clamp((float)play.spawnDelay / play.spawn_delay_get(), 0.0f, 1.0f), - {width, ImGui::GetFrameHeight()}, strings[STRING_InteractSpawnItemButton].c_str()); + ImGui::ProgressBar(1 - std::clamp((float)items.spawnDelay / items.spawn_delay_get(play), 0.0f, 1.0f), + {width, ImGui::GetFrameHeight()}, "Spawn Item"); ImGui::EndDisabled(); - tooltip_set(text_format(strings[STRING_InteractSpawnItemCooldownFormat], play.spawnDelay / TICK_RATE).c_str()); + tooltip_set(text_format("Next item in %0.2fs", items.spawnDelay * TICK).c_str()); } ImGui::PopFont(); + Autofeed& autofeed = play.autofeed; ImGui::PushFont(nullptr, FONT_HEADER_2); - bool isFood = progress.calories > 0 || play.is_spawn_possible(); - for (ItemEntity& entity : play.items) - isFood |= character.items[entity.id].use == USE_EDIBLE; - ImGui::BeginDisabled(!isFood); - StringId autofeedLabel = play.isAutofeed ? STRING_InventoryDisableAutofeedButton : STRING_InventoryEnableAutofeedButton; - if (is_fx(ImGui::Button(strings[autofeedLabel].c_str(), {width, 0}))) - play.isAutofeed = !play.isAutofeed; + ImGui::BeginDisabled(!autofeed.is_available(play)); + if (is_fx(ImGui::Button(autofeed.isEnabled ? "Disable Autofeed" : "Enable Autofeed", {width, 0}))) + autofeed.isEnabled = !autofeed.isEnabled; ImGui::EndDisabled(); - tooltip_set(strings[play.isAutofeed ? STRING_InventoryDisableAutofeedTooltip : STRING_InventoryEnableAutofeedTooltip] - .c_str()); + tooltip_set(autofeed.isEnabled ? "Return to manual interaction." + : "Serve the character food and assist in digestion automatically."); ImGui::PopFont(); if (isRandom || isHelp || isFeeling) ImGui::Separator(); - auto dialogue_button = [&](bool isShown, float size, StringId label, Entry* (Dialogue::*get)(const std::string&), - const std::string& name) { + auto dialogue_button = [&](bool isShown, float size, const char* label, Entry* entry) { if (!isShown) return; ImGui::PushFont(nullptr, size); - if (is_fx(ImGui::Button(strings[label].c_str(), {width, 0}))) - play.text.set(play, (dialogue.*get)(name)); + if (is_fx(ImGui::Button(label, {width, 0}))) + play.text.set(play, entry); ImGui::PopFont(); }; - dialogue_button(isRandom, FONT_HEADER_1, STRING_InteractChatButton, &Dialogue::hook_get, "Random"); - dialogue_button(isHelp, FONT_HEADER_2, STRING_InteractHelpButton, &Dialogue::hook_get, "Help"); - dialogue_button(isFeeling, FONT_HEADER_2, STRING_InteractFeelingButton, &Dialogue::pool_get, feelingPool); + dialogue_button(isRandom, FONT_HEADER_1, "Let's chat!", dialogue.hook_get(HOOK_RANDOM)); + dialogue_button(isHelp, FONT_HEADER_2, "Help", dialogue.hook_get(HOOK_HELP)); + dialogue_button(isFeeling, FONT_HEADER_2, "How are you feeling?", dialogue.pool_get(feelingPool)); } -static void catalog_tab_draw(Play& play, Ui& ui, Renderer& renderer, std::vector& canvases, - std::vector& actors) +// The items as a grid of buttons, with the selected one's details below. Icons are drawn once. +void Ui::catalog_tab_draw(Play& play) { - Character& character = *play.character; - std::vector& items = character.items; - Color characterColor = character.color; - if (actors.size() != items.size()) + Character& data = *play.character; + std::vector& items = data.catalog.items; + if (catalogCharacter != &data || itemCanvases.size() != items.size()) { - actors.clear(); + catalogCharacter = &data; + selectedItem = -1; + itemCanvases.resize(items.size()); + itemSizes.resize(items.size()); for (int i = 0; i < (int)items.size(); i++) - actors.push_back(character.item_actor_get(i)); - canvases.resize(items.size()); + { + Actor actor = data.item_actor_get(i); + itemSizes[i] = canvas_draw(*renderer, itemCanvases[i], actor, {ITEM_SIZE, ITEM_SIZE}); + } } - bool isSelected = ui.selectedItem >= 0 && ui.selectedItem < (int)items.size(); - bool isDiscovered = isSelected && play.discovered[ui.selectedItem]; + bool isSelected = selectedItem >= 0 && selectedItem < (int)items.size(); + bool isDiscovered = isSelected && play.items.discovered[selectedItem]; ImVec2 available = ImGui::GetContentRegionAvail(); float infoHeight = isSelected || items.empty() ? available.y / 2 : 0; @@ -464,258 +757,169 @@ static void catalog_tab_draw(Play& play, Ui& ui, Renderer& renderer, std::vector for (int i = 0; i < (int)items.size(); i++) { ImGui::PushID(i); - bool isFound = play.discovered[i]; - if (isFound && items[i].color.a > 0) - style_set(items[i].color, character.rounding); - bool isSelected = i == ui.selectedItem; - if (isSelected) + bool isColored = play.items.discovered[i] && items[i].color.a > 0; + if (isColored) + style_push(items[i].color); + bool isCurrent = i == selectedItem; + if (isCurrent) for (ImGuiCol id : {ImGuiCol_Button, ImGuiCol_ButtonHovered, ImGuiCol_ButtonActive}) ImGui::PushStyleColor(id, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); - ImVec2 image = canvas_draw(renderer, canvases[i], actors[i], {ITEM_SIZE, ITEM_SIZE}); - ImVec4 tint = isFound ? ImVec4(1, 1, 1, 1) : ImVec4(0, 0, 0, UNDISCOVERED_ALPHA); - ImVec2 padding = {(ITEM_SIZE - image.x) / 2, (ITEM_SIZE - image.y) / 2}; + ImVec2 image = itemSizes[i]; + ImVec4 tint = play.items.discovered[i] ? ImVec4(1, 1, 1, 1) : ImVec4(0, 0, 0, UNDISCOVERED_ALPHA); + ImVec2 padding = ImGui::GetStyle().FramePadding; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, - {ImGui::GetStyle().FramePadding.x + padding.x, ImGui::GetStyle().FramePadding.y + padding.y}); - if (is_fx(ImGui::ImageButton("##Item", texture_id_get(canvases[i].texture), image, {0, 0}, {1, 1}, {}, tint))) - ui.selectedItem = i; + {padding.x + (ITEM_SIZE - image.x) / 2, padding.y + (ITEM_SIZE - image.y) / 2}); + if (is_fx(ImGui::ImageButton("##Item", texture_id_get(itemCanvases[i].texture), image, {0, 0}, {1, 1}, {}, + tint))) + selectedItem = i; ImGui::PopStyleVar(); - if (isSelected) + if (isCurrent) ImGui::PopStyleColor(3); isAnyHovered |= ImGui::IsItemHovered(); - if (!isSelected && ImGui::BeginItemTooltip()) + if (!isCurrent && ImGui::BeginItemTooltip()) { - item_details_draw(character, isFound ? &items[i] : nullptr); + item_details_draw(data, play.items.discovered[i] ? &items[i] : nullptr); ImGui::EndTooltip(); } - if (isFound && items[i].color.a > 0) - style_set(characterColor, character.rounding); + if (isColored) + style_pop(); float next = ImGui::GetItemRectMax().x + ImGui::GetStyle().ItemSpacing.x + ITEM_SIZE; if (i + 1 < (int)items.size() && next < right) ImGui::SameLine(); ImGui::PopID(); } if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !isAnyHovered) - ui.selectedItem = -1; + selectedItem = -1; } ImGui::EndChild(); - if (infoHeight <= 0 || !ImGui::BeginChild("##Info", {available.x, infoHeight})) + if (infoHeight <= 0) return; - ImGui::Separator(); - if (!isSelected) + bool isColored = isDiscovered && items[selectedItem].color.a > 0; + if (isColored) + style_push(items[selectedItem].color); + if (ImGui::BeginChild("##Info", {available.x, infoHeight})) { - ImGui::PushFont(nullptr, FONT_HEADER_2); - ImGui::TextWrapped("%s", character.strings[STRING_InventoryEmptyHint].c_str()); - ImGui::PopFont(); - } - else - { - Item& item = items[ui.selectedItem]; - if (isDiscovered && item.color.a > 0) - style_set(item.color, character.rounding); - ImGui::PushFont(nullptr, FONT_HEADER_2); - ImGui::TextWrapped("%s", isDiscovered ? item.name.c_str() : UNKNOWN); - ImGui::PopFont(); ImGui::Separator(); + ImGui::PushFont(nullptr, FONT_HEADER_2); + ImGui::TextWrapped("%s", !isSelected ? "No items." : isDiscovered ? items[selectedItem].name.c_str() : UNKNOWN); + ImGui::PopFont(); + if (isSelected) + ImGui::Separator(); if (isDiscovered) { float half = (ImGui::GetContentRegionAvail().y - ImGui::GetStyle().ItemSpacing.y) / 2; if (ImGui::BeginChild("##Stats", {0, half})) - item_summary_draw(character, item); + item_summary_draw(data, items[selectedItem]); ImGui::EndChild(); ImGui::Separator(); if (ImGui::BeginChild("##Description", {0, half})) - ImGui::TextWrapped("%s", item.description.c_str()); + ImGui::TextWrapped("%s", items[selectedItem].description.c_str()); ImGui::EndChild(); } - else + else if (isSelected) ImGui::TextWrapped("%s", UNKNOWN); - if (isDiscovered && item.color.a > 0) - style_set(characterColor, character.rounding); } ImGui::EndChild(); + if (isColored) + style_pop(); } -static void cheats_tab_draw(Play& play) +void Ui::cheats_tab_draw(Play& play) { - Character& character = *play.character; - Progress& progress = play.progress; - std::array& strings = character.strings; - if (!ImGui::BeginChild("##Cheats")) - return ImGui::EndChild(); - is_fx(ImGui::SliderFloat(strings[STRING_CheatsCalories].c_str(), &progress.calories, 0, play.capacity_max_get(), - "%0.0f kcal")); - is_fx(ImGui::SliderFloat(strings[STRING_CheatsCapacity].c_str(), &progress.capacity, character.capacityMin, - character.capacityMax, "%0.0f kcal")); - if (is_fx(ImGui::SliderFloat(strings[STRING_CheatsWeight].c_str(), &progress.weight, character.weight, - character.weightMax, strings[STRING_CheatsWeightFormat].c_str()))) - play.idle_play(); - int stage = play.stage + 1; - if (is_fx(ImGui::SliderInt(strings[STRING_CheatsStage].c_str(), &stage, 1, (int)character.stages.size() + 1))) + Traits& traits = play.character->traits; + Body& body = play.body; + Stats& stats = body.stats; + if (ImGui::BeginChild("##Cheats")) { - play.stage = stage - 1; - progress.weight = play.stage_threshold_get(play.stage); - play.idle_play(); + is_fx(ImGui::SliderFloat("Calories", &stats.calories, 0, body.capacity_max_get(), "%0.0f kcal")); + is_fx(ImGui::SliderFloat("Capacity", &stats.capacity, traits.capacityMin, traits.capacityMax, "%0.0f kcal")); + if (is_fx(ImGui::SliderFloat("Weight", &stats.weight, traits.weight, traits.weightMax, "%0.2f kg"))) + { + body.stage_set(body.stage_get(stats.weight)); + body.idle_play(); + } + int stage = body.stage + 1; + if (is_fx(ImGui::SliderInt("Stage", &stage, 1, (int)play.character->stages.size() + 1))) + { + body.stage_set(stage - 1); + stats.weight = body.stage_threshold_get(body.stage); + body.idle_play(); + } + float rate = stats.digestionRate * TICK_RATE; + if (is_fx(ImGui::SliderFloat("Digestion Rate", &rate, traits.digestionRateMin * TICK_RATE, + traits.digestionRateMax * TICK_RATE, "%0.2f%% / sec"))) + stats.digestionRate = rate / TICK_RATE; + if (is_fx(ImGui::Button("Digest"))) + stats.digestionProgress = DIGESTION_MAX; } - float rate = progress.digestionRate * TICK_RATE; - if (is_fx(ImGui::SliderFloat(strings[STRING_CheatsDigestionRate].c_str(), &rate, character.digestionRateMin * TICK_RATE, - character.digestionRateMax * TICK_RATE, strings[STRING_CheatsDigestionRateFormat].c_str()))) - progress.digestionRate = rate / TICK_RATE; - if (is_fx(ImGui::Button(strings[STRING_CheatsDigestButton].c_str()))) - progress.digestionProgress = DIGESTION_MAX; ImGui::EndChild(); } -static void tools_draw(Play& play, ImVec2 position, ImVec2 size) +void Ui::tools_draw(Play& play, ImVec2 position, ImVec2 size) { - Character& character = *play.character; + Character& data = *play.character; ImGui::SetNextWindowSize(size); ImGui::SetNextWindowPos(position); - if (!ImGui::Begin("##Tools", nullptr, PANEL_FLAGS)) - return ImGui::End(); - float side = ImGui::GetContentRegionAvail().x; - auto tool_button = [&](const char* id, const std::string& label, const std::string& glyph, - const std::string& tooltip, bool isActive, bool isDisabled) { - bool isIcon = character.iconsFont && !glyph.empty(); - ImGui::PushID(id); - if (isActive) - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); - ImGui::BeginDisabled(isDisabled); - bool isPressed = is_fx(ImGui::Button(isIcon ? "##Icon" : label.c_str(), {side, side})); - if (isIcon) - { - ImVec2 min = ImGui::GetItemRectMin(); - ImVec2 itemSize = ImGui::GetItemRectSize(); - ImVec2 textSize = character.iconsFont->CalcTextSizeA(itemSize.y, FLT_MAX, 0, glyph.c_str()); - ImGui::GetWindowDrawList()->AddText(character.iconsFont, itemSize.y, - {min.x + (itemSize.x - textSize.x) / 2, min.y + (itemSize.y - textSize.y) / 2}, - ImGui::GetColorU32(ImGuiCol_Text), glyph.c_str()); - } - ImGui::EndDisabled(); - if (isActive) - ImGui::PopStyleColor(); - if (!tooltip.empty()) - ImGui::SetItemTooltip("%s", tooltip.c_str()); - ImGui::PopID(); - return isPressed; - }; - if (tool_button("Home", character.strings[STRING_ToolsHomeButton], character.homeGlyph, - character.strings[STRING_ToolsHomeTooltip], false, false)) - play.focus({ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y}); - for (int i = 0; i < (int)character.interactTypes.size(); i++) + if (ImGui::Begin("##Tools", nullptr, PANEL_FLAGS)) { - InteractArea& type = character.interactTypes[i]; - std::string tooltip = play.isAutofeed ? character.strings[STRING_ToolsAutofeedDisabledTooltip] - : character.iconsFont && !type.glyph.empty() ? type.name - : ""; - if (tool_button(type.name.c_str(), type.name, type.glyph, tooltip, play.cursorType == i, play.isAutofeed)) - play.cursorType = i; + float side = ImGui::GetContentRegionAvail().x; + ImFont* icons = data.menu.iconsFont; + auto tool_button = [&](const char* id, const std::string& glyph, const char* tooltip, bool isActive, + bool isDisabled) { + bool isIcon = icons && !glyph.empty(); + ImGui::PushID(id); + if (isActive) + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); + ImGui::BeginDisabled(isDisabled); + bool isPressed = is_fx(ImGui::Button(isIcon ? "##Icon" : id, {side, side})); + if (isIcon) + { + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 itemSize = ImGui::GetItemRectSize(); + ImVec2 textSize = icons->CalcTextSizeA(itemSize.y, FLT_MAX, 0, glyph.c_str()); + ImGui::GetWindowDrawList()->AddText( + icons, itemSize.y, {min.x + (itemSize.x - textSize.x) / 2, min.y + (itemSize.y - textSize.y) / 2}, + ImGui::GetColorU32(ImGuiCol_Text), glyph.c_str()); + } + ImGui::EndDisabled(); + if (isActive) + ImGui::PopStyleColor(); + if (*tooltip) + ImGui::SetItemTooltip("%s", tooltip); + ImGui::PopID(); + return isPressed; + }; + if (tool_button("Home", data.menu.homeGlyph, "Reset camera view.\n(Shortcut: Home)", false, false)) + play.focus({ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y}); + for (int i = 0; i < (int)data.interactTypes.size(); i++) + { + InteractArea& type = data.interactTypes[i]; + const char* tooltip = play.autofeed.isEnabled ? "Disabled while Autofeed is active!" + : icons && !type.glyph.empty() ? type.name.c_str() + : ""; + if (tool_button(type.name.c_str(), type.glyph, tooltip, play.cursorType == i, play.autofeed.isEnabled)) + play.cursorType = i; + } } ImGui::End(); } -static void toasts_draw(Play& play) +void Ui::play_update(Play& play, Settings& settings) { - ImGuiStyle& style = ImGui::GetStyle(); - ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImDrawList* drawList = ImGui::GetForegroundDrawList(); - float height = ImGui::GetTextLineHeightWithSpacing() + style.WindowPadding.y * 2; - for (int i = 0; i < (int)play.toasts.size(); i++) - { - Toast& toast = play.toasts[i]; - float alpha = (float)toast.life / TOAST_LIFE; - ImVec2 position = {viewport->WorkPos.x + style.WindowPadding.x, - viewport->WorkPos.y + viewport->WorkSize.y - style.WindowPadding.y - - height * (play.toasts.size() - i)}; - ImVec2 end = {position.x + ImGui::CalcTextSize(toast.message.c_str()).x + style.WindowPadding.x * 2, - position.y + height}; - auto color_get = [&](ImGuiCol id) { - ImVec4 color = ImGui::GetStyleColorVec4(id); - color.w = alpha; - return ImGui::GetColorU32(color); - }; - drawList->AddRectFilled(position, end, color_get(ImGuiCol_WindowBg), style.WindowRounding); - drawList->AddRect(position, end, color_get(ImGuiCol_Border), style.WindowRounding); - drawList->AddText({position.x + style.WindowPadding.x, position.y + style.WindowPadding.y}, - color_get(ImGuiCol_Text), toast.message.c_str()); - } -} - -void Ui::settings_update(Settings& settings, Audio& audio, const Character* character) -{ - auto string_get = [&](StringId id) { - return character ? character->strings[id].c_str() : STRING_DEFAULTS[id]; - }; - ImGui::SeparatorText(string_get(STRING_SettingsMeasurementSystem)); - is_fx(ImGui::RadioButton(string_get(STRING_SettingsMetric), (int*)&settings.measurement, MEASUREMENT_METRIC)); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsMetricTooltip)); - ImGui::SameLine(); - is_fx(ImGui::RadioButton(string_get(STRING_SettingsImperial), (int*)&settings.measurement, MEASUREMENT_IMPERIAL)); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsImperialTooltip)); - - ImGui::SeparatorText(string_get(STRING_SettingsWindow)); - const char* modes[] = {string_get(STRING_SettingsWindowed), string_get(STRING_SettingsFullscreen)}; - int mode = isFullscreen; - if (is_fx(ImGui::Combo(string_get(STRING_SettingsWindowMode), &mode, modes, 2))) - isFullscreenToggled = true; - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsWindowModeTooltip)); - - ImGui::SeparatorText(string_get(STRING_SettingsSound)); - ImGui::BeginDisabled(settings.isMuted); - if (is_fx(ImGui::SliderInt(string_get(STRING_SettingsVolume), &settings.volume, 0, (int)PERCENT, "%d%%"))) - audio.gain_set(settings.volume / PERCENT); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsVolumeTooltip)); - ImGui::EndDisabled(); - ImGui::SameLine(); - if (is_fx(ImGui::Checkbox(string_get(STRING_SettingsMute), &settings.isMuted))) - audio.gain_set(settings.isMuted ? 0 : settings.volume / PERCENT); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsMuteTooltip)); - - ImGui::SeparatorText(string_get(STRING_SettingsAppearance)); - bool isColorChanged = is_fx(ImGui::Checkbox(string_get(STRING_SettingsUseCharacterColor), &settings.isCharacterColor)); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsUseCharacterColorTooltip)); - ImGui::SameLine(); - ImGui::BeginDisabled(settings.isCharacterColor); - isColorChanged |= is_fx(ImGui::ColorEdit3(string_get(STRING_SettingsColor), &settings.color.r, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoTooltip)); - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsColorTooltip)); - ImGui::EndDisabled(); - ImGui::Separator(); - if (is_fx(ImGui::Button(string_get(STRING_SettingsResetButton), {-FLT_MIN, 0}))) - { - settings.reset(); - audio.gain_set(settings.volume / PERCENT); - isColorChanged = true; - } - if (isColorChanged) - style_set(character && settings.isCharacterColor ? character->color : settings.color, - character ? character->rounding : 0); - if (!character) - return; - ImGui::Separator(); - if (is_fx(ImGui::Button(string_get(STRING_SettingsSaveButton), {-FLT_MIN, 0}))) - isSave = true; - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsSaveTooltip)); - if (is_fx(ImGui::Button(string_get(STRING_SettingsReturnToCharactersButton), {-FLT_MIN, 0}))) - isReturn = true; - ImGui::SetItemTooltip("%s", string_get(STRING_SettingsReturnToCharactersTooltip)); -} - -void Ui::play_update(Play& play, Settings& settings, Renderer& renderer, Audio& audio) -{ - Character& character = *play.character; - std::array& strings = character.strings; + character = play.character; ImGuiStyle& style = ImGui::GetStyle(); ImVec2 screen = ImGui::GetIO().DisplaySize; float barWidth = std::max(ImGui::GetTextLineHeightWithSpacing(), style.WindowMinSize.x); float height = screen.y - style.WindowPadding.y * 2; - if (play.tooltipItem >= 0 && ImGui::BeginTooltip()) + if (play.items.tooltip >= 0 && ImGui::BeginTooltip()) { - item_details_draw(character, &character.items[play.tooltipItem]); + item_details_draw(*character, &character->catalog.items[play.items.tooltip]); ImGui::EndTooltip(); } toasts_draw(play); + text_draw(play, screen); if (play.text.isScene) return; @@ -726,8 +930,8 @@ void Ui::play_update(Play& play, Settings& settings, Renderer& renderer, Audio& float toolsX = -toolsWidth + toolsWidth * std::pow(toolsSlide, SLIDE_EASE); if (toolsSlide > 0) tools_draw(play, {toolsX, style.WindowPadding.y}, {toolsWidth, height}); - panel_bar_draw(play, play.isToolsOpen, toolsSlide, {toolsX + toolsWidth, style.WindowPadding.y}, {barWidth, height}, - DIRECTION_RIGHT, STRING_ToolsOpenTooltip, STRING_ToolsCloseTooltip); + bar_draw(play, play.isToolsOpen, toolsSlide, {toolsX + toolsWidth, style.WindowPadding.y}, {barWidth, height}, + DIRECTION_RIGHT, "Open Tools", "Close Tools"); slide_update(menuSlide, play.isMenuOpen); float menuWidth = screen.x * MENU_WIDTH; @@ -738,22 +942,22 @@ void Ui::play_update(Play& play, Settings& settings, Renderer& renderer, Audio& ImGui::SetNextWindowPos({menuX, style.WindowPadding.y}); if (ImGui::Begin("##Menu", nullptr, PANEL_FLAGS) && ImGui::BeginTabBar("##Tabs")) { - if (is_fx(ImGui::BeginTabItem(strings[STRING_MenuTabInteract].c_str()))) + if (is_fx(ImGui::BeginTabItem("Interact"))) { interact_tab_draw(play, settings); ImGui::EndTabItem(); } - if (is_fx(ImGui::BeginTabItem(strings[STRING_MenuTabInventory].c_str()))) + if (is_fx(ImGui::BeginTabItem("Catalog"))) { - catalog_tab_draw(play, *this, renderer, itemCanvases, itemActors); + catalog_tab_draw(play); ImGui::EndTabItem(); } - if (is_fx(ImGui::BeginTabItem(strings[STRING_MenuTabSettings].c_str()))) + if (is_fx(ImGui::BeginTabItem("Settings"))) { - settings_update(settings, audio, &character); + settings_draw(settings); ImGui::EndTabItem(); } - if (play.isCheats && is_fx(ImGui::BeginTabItem(strings[STRING_MenuTabCheats].c_str()))) + if (play.isCheats && is_fx(ImGui::BeginTabItem("Cheats"))) { cheats_tab_draw(play); ImGui::EndTabItem(); @@ -762,28 +966,29 @@ void Ui::play_update(Play& play, Settings& settings, Renderer& renderer, Audio& } ImGui::End(); } - panel_bar_draw(play, play.isMenuOpen, menuSlide, {menuX - barWidth, style.WindowPadding.y}, {barWidth, height}, - DIRECTION_LEFT, STRING_MenuOpenTooltip, STRING_MenuCloseTooltip); + bar_draw(play, play.isMenuOpen, menuSlide, {menuX - barWidth, style.WindowPadding.y}, {barWidth, height}, + DIRECTION_LEFT, "Open Main Menu", "Close Main Menu"); ImGui::PopStyleVar(2); } -void Ui::select_update(std::vector& characters, Settings& settings, Renderer& renderer, Audio& audio) +void Ui::select_update(std::vector& characters, Settings& settings) { ImGuiStyle& style = ImGui::GetStyle(); ImVec2 screen = ImGui::GetIO().DisplaySize; ImVec2 half = {screen.x / 2 - style.WindowPadding.x * 2, screen.y / 2 - style.WindowPadding.y * 2}; - isRefresh = false; + character = nullptr; characterIndex = characters.empty() ? -1 : std::clamp(characterIndex, 0, (int)characters.size() - 1); Character* selected = characterIndex >= 0 ? &characters[characterIndex] : nullptr; - if (selected && progressIndex != characterIndex) + if (selected && selectedIndex != characterIndex) { - selectedProgress.load(selected->save_path_get()); - progressIndex = characterIndex; + selectedStats = {}; + isSelectedSaved = is_fields_read(selected->save_path_get(), selectedStats); + selectedIndex = characterIndex; } if (selected) { - style_set(selected->color); + style_push(selected->color); ImGui::SetNextWindowSize(half); ImGui::SetNextWindowPos({style.WindowPadding.x, style.WindowPadding.y}); if (ImGui::Begin("##Preview", nullptr, PANEL_FLAGS) && ImGui::BeginTabBar("##Preview Tabs")) @@ -803,14 +1008,13 @@ void Ui::select_update(std::vector& characters, Settings& settings, R ImGui::TextWrapped("%s\n%s", selected->name.c_str(), selected->description.c_str()); ImGui::EndTabItem(); } - isPreview = is_fx(ImGui::BeginTabItem("In Game")); - if (isPreview) + if (is_fx(ImGui::BeginTabItem("In Game"))) { - Anm2* anm2 = selected->anm2_get(selected->anm2Path, renderer, audio); + Anm2* anm2 = selected->anm2_get(selected->anm2Path, *renderer, *audio); if (previewActor.anm2 != anm2 || !anm2) previewActor = Actor(anm2); - previewActor.update(audio); - ImVec2 size = canvas_draw(renderer, previewCanvas, previewActor, available); + previewActor.update(*audio); + ImVec2 size = canvas_draw(*renderer, previewCanvas, previewActor, available); ImGui::SetCursorPos({ImGui::GetCursorPosX() + (available.x - size.x) / 2, ImGui::GetCursorPosY() + (available.y - size.y) / 2}); ImGui::Image(texture_id_get(previewCanvas.texture), size); @@ -843,7 +1047,7 @@ void Ui::select_update(std::vector& characters, Settings& settings, R ImGui::PopStyleColor(); ImGui::Separator(); } - float weight = selectedProgress.isValid ? selectedProgress.weight : selected->weight; + float weight = isSelectedSaved ? selectedStats.weight : selected->traits.weight; ImGui::Text("Weight: %s", weight_format(weight, settings.measurement).c_str()); ImGui::Text("Stages: %i", (int)selected->stages.size() + 1); ImGui::EndTabItem(); @@ -852,7 +1056,8 @@ void Ui::select_update(std::vector& characters, Settings& settings, R { ImGui::Separator(); ImGui::PushStyleColor(ImGuiCol_Text, imvec4_get(GRAY)); - ImGui::TextWrapped("%s", selected->credits.empty() ? "No credits listed." : selected->credits.c_str()); + ImGui::TextWrapped("%s", selected->credits.empty() ? "No credits listed." + : selected->credits.c_str()); ImGui::PopStyleColor(); ImGui::EndTabItem(); } @@ -863,19 +1068,21 @@ void Ui::select_update(std::vector& characters, Settings& settings, R } ImGui::EndChild(); - ImVec2 buttonSize = {row_width_get(selectedProgress.isValid ? 2 : 1), 0}; - if (selectedProgress.isValid) + ImVec2 buttonSize = {row_width_get(isSelectedSaved ? 2 : 1), 0}; + if (isSelectedSaved) { - isContinue = is_fx(ImGui::Button("Continue", buttonSize)); + if (is_fx(ImGui::Button("Continue", buttonSize))) + command_add(COMMAND_CONTINUE, selected->key); tooltip_set("Continue from a saved game."); ImGui::SameLine(); } if (is_fx(ImGui::Button("New Game", buttonSize))) { - isNewGame = !selectedProgress.isValid; - isNewGameWarning = selectedProgress.isValid; - if (isNewGameWarning) + if (isSelectedSaved) ImGui::OpenPopup("New Game Warning"); + else + command_add(COMMAND_NEW_GAME, selected->key); + isNewGameWarning = isSelectedSaved; } tooltip_set("Start a new game.\nThis will delete progress!"); ImGui::PopFont(); @@ -887,7 +1094,8 @@ void Ui::select_update(std::vector& characters, Settings& settings, R { ImGui::TextWrapped("This will delete saved progress! Are you sure?"); ImVec2 popupSize = {row_width_get(2), 0}; - isNewGame = is_fx(ImGui::Button("Yes", popupSize)); + if (is_fx(ImGui::Button("Yes", popupSize))) + command_add(COMMAND_NEW_GAME, selected->key); ImGui::SameLine(); if (is_fx(ImGui::Button("No", popupSize))) isNewGameWarning = false; @@ -895,9 +1103,9 @@ void Ui::select_update(std::vector& characters, Settings& settings, R } } ImGui::End(); + style_pop(); } - style_set(settings.color); ImGui::SetNextWindowSize({screen.x / 2 - style.WindowPadding.x, screen.y - style.WindowPadding.y * 2}); ImGui::SetNextWindowPos({screen.x / 2, style.WindowPadding.y}); if (ImGui::Begin("##Main Menu", nullptr, PANEL_FLAGS) && ImGui::BeginTabBar("##Main Tabs")) @@ -906,44 +1114,45 @@ void Ui::select_update(std::vector& characters, Settings& settings, R { if (ImGui::BeginPopupContextWindow("##Characters Context")) { - isRefresh = ImGui::MenuItem("Refresh"); + if (ImGui::MenuItem("Refresh")) + { + command_add(COMMAND_RELOAD); + selectedIndex = -1; + } ImGui::EndPopup(); } float side = ImGui::GetContentRegionAvail().x / 4; float right = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x; for (int i = 0; i < (int)characters.size(); i++) { - Character& character = characters[i]; + Character& entry = characters[i]; ImGui::PushID(i); - style_set(character.color); - bool isSelected = i == characterIndex; - if (isSelected) + style_push(entry.color); + bool isCurrent = i == characterIndex; + if (isCurrent) ImGui::PushStyleColor(ImGuiCol_FrameBg, ImGui::GetStyleColorVec4(ImGuiCol_FrameBgHovered)); - bool isPressed = character.portrait - ? ImGui::ImageButton(character.name.c_str(), texture_id_get(*character.portrait), - {side, side}) - : ImGui::Button(character.name.c_str(), {side, side}); + bool isPressed = entry.portrait ? ImGui::ImageButton(entry.name.c_str(), + texture_id_get(*entry.portrait), {side, side}) + : ImGui::Button(entry.name.c_str(), {side, side}); if (is_fx(isPressed)) characterIndex = i; - if (isSelected) + if (isCurrent) ImGui::PopStyleColor(); - ImGui::SetItemTooltip("%s", character.name.c_str()); + ImGui::SetItemTooltip("%s", entry.name.c_str()); + style_pop(); float next = ImGui::GetItemRectMax().x + style.ItemSpacing.x + side; if (i + 1 < (int)characters.size() && next < right) ImGui::SameLine(); - style_set(settings.color); ImGui::PopID(); } ImGui::EndTabItem(); } if (is_fx(ImGui::BeginTabItem("Settings"))) { - settings_update(settings, audio, nullptr); + settings_draw(settings); ImGui::EndTabItem(); } ImGui::EndTabBar(); } ImGui::End(); - if (isRefresh) - progressIndex = -1; } diff --git a/src/ui.h b/src/ui.h index 1979e13..088c446 100644 --- a/src/ui.h +++ b/src/ui.h @@ -18,52 +18,63 @@ enum Direction DIRECTION_RIGHT }; -// Hover and select sounds (from the character being played, or none) and the pulsing border on hovered widgets. -void widget_sounds_set(Audio* audio, const Sounds* hover, const Sounds* select); -void widget_fx(); - -// Passes a widget's result through after giving it its sounds and hover border. -inline bool is_fx(bool result) +enum CommandType { - widget_fx(); - return result; -} + COMMAND_CONTINUE, + COMMAND_NEW_GAME, + COMMAND_MENU, + COMMAND_SAVE, + COMMAND_RELOAD, + COMMAND_FULLSCREEN +}; -inline ImVec4 imvec4_get(Color color) +// A request, from the UI or the console, for the game to change what's running. Character names a character's folder. +struct Command { - return {color.r, color.g, color.b, color.a}; -} + std::string character; + CommandType type = COMMAND_MENU; +}; + +using Commands = std::vector; void style_set(Color color, float rounding = 0); -float row_width_get(int count); -void tooltip_set(const char* text, float size = FONT_NORMAL); -void triangle_draw(ImVec2 position, ImVec2 size, Direction direction); -// The menus, tools, toasts and select screen. +// The menus, tools, dialogue box, toasts and select screen. class Ui { public: - std::vector itemActors; std::vector itemCanvases; + std::vector itemSizes; Actor previewActor; Canvas previewCanvas; - Progress selectedProgress; + Stats selectedStats; + Audio* audio = nullptr; + Character* catalogCharacter = nullptr; + Character* character = nullptr; + Commands* commands = nullptr; + Renderer* renderer = nullptr; float menuSlide = 1; float toolsSlide = 0; int characterIndex = 0; - int progressIndex = -1; + int selectedIndex = -1; int selectedItem = -1; - bool isContinue = false; bool isFullscreen = false; - bool isFullscreenToggled = false; - bool isNewGame = false; bool isNewGameWarning = false; - bool isPreview = false; - bool isRefresh = false; - bool isReturn = false; - bool isSave = false; + bool isSelectedSaved = false; - void play_update(Play& play, Settings& settings, Renderer& renderer, Audio& audio); - void select_update(std::vector& characters, Settings& settings, Renderer& renderer, Audio& audio); - void settings_update(Settings& settings, Audio& audio, const Character* character); + void play_update(Play& play, Settings& settings); + void select_update(std::vector& characters, Settings& settings); + +private: + bool is_fx(bool result); + void command_add(CommandType type, const std::string& key = ""); + void settings_draw(Settings& settings); + void interact_tab_draw(Play& play, Settings& settings); + void catalog_tab_draw(Play& play); + void cheats_tab_draw(Play& play); + void tools_draw(Play& play, ImVec2 position, ImVec2 size); + void bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction, + const char* openTooltip, const char* closeTooltip); + void text_draw(Play& play, ImVec2 screen); + void toasts_draw(Play& play); };