Split play into body, items, interactions, autofeed and camera, with events, commands and literal strings

This commit is contained in:
shweet committed 2026-11-12 22:14:04 -05:00
1 parent 167f1a5ddd
commit 3e9a0cbd34
21 files changed
+2461 -2388

No files matched your search

-3
View File
@@ -206,9 +206,6 @@ Determines menu and general UI appearance and behavior.
### particles ### particles
An object of particles by name, each with the `anm2` and `animation` it plays once where spawned. 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 # items.json
Has all items and their parameters, alongside additional behavior. Has all items and their parameters, alongside additional behavior.
+137 -99
View File
@@ -174,6 +174,9 @@ void Anm2::load(const std::string& path, Renderer& renderer, Audio& audio)
sounds = audio.sounds_get(folder, soundPaths); sounds = audio.sounds_get(folder, soundPaths);
animations_parse(json_object(root, "animations"), animations, sheetRegions, layerSheets); 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"))); defaultAnimation = std::max(0, animation_get(json_string(root, "defaultAnimation")));
} }
@@ -185,10 +188,8 @@ static int name_get(const std::vector<std::string>& names, const std::string& na
int Anm2::animation_get(const std::string& name) const int Anm2::animation_get(const std::string& name) const
{ {
for (int i = 0; i < (int)animations.size(); i++) auto found = animationIndices.find(name);
if (animations[i].name == name) return found == animationIndices.end() ? -1 : found->second;
return i;
return -1;
} }
int Anm2::event_get(const std::string& name) const 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) Actor::Actor(Anm2* anm2, Vec2 position, const std::string& name) : anm2(anm2), position(position)
{ {
if (!anm2) if (!anm2)
return; return;
sheets = anm2->sheets; sheets = anm2->sheets;
layers.resize(anm2->layers.size());
nullScales.resize(anm2->nulls.size());
int index = anm2->animation_get(name); int index = anm2->animation_get(name);
request_apply({anm2->animations[index >= 0 ? index : anm2->defaultAnimation].name, TRANSITION_FORCE}); 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) bool Actor::is_playing(const std::string& name)
{ {
Anm2Animation* current = animation_get(); return isPlaying && anm2 && animation >= 0 && anm2->animation_get(name) == animation;
return isPlaying && current && current->name == name;
} }
bool Actor::is_finished() bool Actor::is_finished()
@@ -268,8 +343,7 @@ void Actor::request_apply(const Request& request)
int index = anm2 ? anm2->animation_get(request.animation) : -1; int index = anm2 ? anm2->animation_get(request.animation) : -1;
if (index < 0) if (index < 0)
{ {
static std::unordered_set<std::string> missing; if (anm2 && anm2->missing.insert(request.animation).second)
if (anm2 && missing.insert(request.animation).second)
SDL_Log("Animation \"%s\" does not exist", request.animation.c_str()); SDL_Log("Animation \"%s\" does not exist", request.animation.c_str());
return; return;
} }
@@ -289,23 +363,20 @@ void Actor::request_apply(const Request& request)
triggersPlayed.assign(anm2->animations[index].triggers.size(), false); 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) void Actor::update(Audio& audio)
{ {
if (pending) if (pending)
request_apply(*pending); request_apply(*pending);
pending.reset(); pending.reset();
for (int i = 0; i < (int)pulses.size(); i++)
for (int i = 0; i < (int)overrides.size(); i++) if (--pulses[i].time <= 0)
{ pulses.erase(pulses.begin() + 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--);
}
Anm2Animation* current = animation_get(); Anm2Animation* current = animation_get();
if (!isPlaying || (current && current->isLoop)) if (!isPlaying || (current && current->isLoop))
@@ -339,62 +410,50 @@ void Actor::update(Audio& audio)
triggersPlayed.assign(triggersPlayed.size(), false); 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 // A layer's keyframe at a time, with its swapped region, added scale and pulses.
// the last keyframe holds. An empty track gives an invisible frame. Anm2Frame Actor::layer_frame_get(const Anm2Item& layer, float frameTime)
Anm2Frame Actor::frame_get(const Anm2Track& track, float frameTime, OverrideTarget target, int id)
{ {
Anm2Frame frame; Anm2Frame frame = track_sample(layer.frames, frameTime);
if (track.empty()) if (layer.id < 0 || layer.id >= (int)layers.size())
{
frame.isVisible = false;
return frame; return frame;
LayerChange& change = layers[layer.id];
if (change.isRegion)
{
frame.crop = change.crop;
frame.size = change.size;
frame.pivot = change.pivot;
} }
frameTime = std::max(frameTime, 0.0f); frame.scale = frame.scale + change.scale;
float start = 0; for (Pulse& pulse : pulses)
for (size_t i = 0; i < track.size(); i++)
{ {
frame = track[i]; if (pulse.layer != layer.id)
if (frameTime >= start + frame.delay && i + 1 < track.size())
{
start += frame.delay;
continue; continue;
} float percent = pulse.time / pulse.duration;
if (frame.easing != EASING_NONE && i + 1 < track.size() && frame.delay > 1) float wave = std::abs(pulse.amplitude) * std::cos((1 - percent) * 2 * PI * pulse.cycles) * percent;
{ frame.scale = frame.scale + Vec2{wave, wave};
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;
}
for (Override& override : overrides)
{
if (override.target != target || override.id != 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;
} }
return frame; 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. // 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()) if (group < 0 || group >= (int)current.groups.size())
return true; return true;
@@ -403,24 +462,19 @@ bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime,
return false; return false;
if (entry.root.empty()) if (entry.root.empty())
return true; return true;
Anm2Frame frame = frame_get(entry.root, frameTime); Anm2Frame frame = track_sample(entry.root, frameTime);
model = model * root_affine_get(frame); model = model * root_affine_get(frame);
tint = tint * frame.tint; tint = tint * frame.tint;
offset = offset + frame.offset; offset = offset + frame.offset;
return frame.isVisible; 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) void Actor::render(Renderer& renderer, Affine view)
{ {
Anm2Animation* current = animation_get(); Anm2Animation* current = animation_get();
if (!isVisible || !current) if (!isVisible || !current)
return; return;
Anm2Frame root = frame_get(current->root, time); Anm2Frame root = root_frame_get(*current, time);
Affine rootModel = view * root_affine_get(root, position); Affine rootModel = view * root_affine_get(root, position);
for (Anm2Item& layer : current->layers) for (Anm2Item& layer : current->layers)
{ {
@@ -429,25 +483,25 @@ void Actor::render(Renderer& renderer, Affine view)
Color offset = root.offset; Color offset = root.offset;
if (!layer.isVisible || !is_group_visible(*current, layer.group, time, model, tint, offset)) if (!layer.isVisible || !is_group_visible(*current, layer.group, time, model, tint, offset))
continue; 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; 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; Texture* texture = sheet >= 0 && sheet < (int)sheets.size() ? sheets[sheet] : nullptr;
if (!frame.isVisible || !texture) if (!frame.isVisible || !texture)
continue; 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, renderer.quad_draw(*texture, {frame.crop.x, frame.crop.y, frame.size.x, frame.size.y}, model,
frame.tint * tint, frame.offset + offset); frame.tint * tint, frame.offset + offset);
if (isShowLayers) if (renderer.isShowLayers)
renderer.outline_draw(model, frame.size.x, frame.size.y, LAYER_COLOR); renderer.outline_draw(model, frame.size.x, frame.size.y, LAYER_COLOR);
} }
if (!isShowNulls) if (!renderer.isShowNulls)
return; return;
for (Anm2Item& null : current->nulls) for (Anm2Item& null : current->nulls)
{ {
Affine model = rootModel; Affine model = rootModel;
Color tint; Color tint;
Color offset; 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)) 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); 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) if (item.id != null)
continue; 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 tint;
Color offset; 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)) if (!frame.isVisible || !is_group_visible(*current, item.group, frameTime, model, tint, offset))
break; break;
return affine_bounds(model * null_affine_get(frame), frame.scale.x, frame.scale.y); 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}; 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 Actor::bounds_get()
{ {
Rect bounds = {NAN, NAN, NAN, NAN}; Rect local = anm2 ? anm2->bounds_get(animation) : Rect{NAN, NAN, NAN, NAN};
Anm2Animation* current = animation_get(); if (!is_rect_valid(local))
if (!current) return local;
return bounds; Affine model = affine_get(position, rotation.value_or(0), {1, 1}) * Affine{1, 0, 0, 1, local.x, local.y};
for (int frameTime = 0; frameTime < current->length; frameTime++) return affine_bounds(model, local.w, local.h);
{
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;
} }
+31 -25
View File
@@ -4,6 +4,8 @@
#include "renderer.h" #include "renderer.h"
#include <optional> #include <optional>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
enum Easing enum Easing
@@ -75,10 +77,13 @@ struct Anm2Animation
class Anm2 class Anm2
{ {
public: public:
std::unordered_map<std::string, int> animationIndices;
std::unordered_set<std::string> missing;
std::vector<Anm2Animation> animations; std::vector<Anm2Animation> animations;
std::vector<std::string> events; std::vector<std::string> events;
std::vector<std::string> layers; std::vector<std::string> layers;
std::vector<std::string> nulls; std::vector<std::string> nulls;
std::vector<Rect> bounds;
std::vector<Texture*> sheets; std::vector<Texture*> sheets;
std::vector<int> layerSheets; std::vector<int> layerSheets;
Sounds sounds; Sounds sounds;
@@ -90,9 +95,11 @@ public:
int event_get(const std::string& name) const; int event_get(const std::string& name) const;
int layer_get(const std::string& name) const; int layer_get(const std::string& name) const;
int null_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); int track_length_get(const Anm2Track& track);
Anm2Frame track_sample(const Anm2Track& track, float time);
enum Transition enum Transition
{ {
@@ -108,30 +115,24 @@ enum Interrupt
INTERRUPT_NEVER 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 crop;
Vec2 pivot; Vec2 pivot;
Vec2 scale; Vec2 scale;
Vec2 size; 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 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 struct Request
@@ -141,17 +142,18 @@ struct Request
Interrupt interrupt = INTERRUPT_IF_ALLOWED; 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 class Actor
{ {
public: public:
static inline bool isShowLayers = false;
static inline bool isShowNulls = false;
Anm2* anm2 = nullptr; Anm2* anm2 = nullptr;
std::optional<Request> pending; std::optional<Request> pending;
std::vector<Override> overrides; std::optional<float> rotation;
std::vector<LayerChange> layers;
std::vector<Pulse> pulses;
std::vector<Texture*> sheets; std::vector<Texture*> sheets;
std::vector<Vec2> nullScales;
std::vector<bool> triggersPlayed; std::vector<bool> triggersPlayed;
Vec2 position; Vec2 position;
float time = 0; float time = 0;
@@ -167,16 +169,20 @@ public:
Actor(Anm2* anm2, Vec2 position = {}, const std::string& animation = ""); Actor(Anm2* anm2, Vec2 position = {}, const std::string& animation = "");
void play(const std::string& name, Transition transition = TRANSITION_NORMAL, void play(const std::string& name, Transition transition = TRANSITION_NORMAL,
Interrupt interrupt = INTERRUPT_IF_ALLOWED); Interrupt interrupt = INTERRUPT_IF_ALLOWED);
void pulse_add(int layer, float amplitude, float cycles, float duration);
void update(Audio& audio); void update(Audio& audio);
void render(Renderer& renderer, Affine view); void render(Renderer& renderer, Affine view);
Anm2Animation* animation_get(int index = -1); 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 null_rect_get(int null, int index = -1, float frameTime = -1);
Rect bounds_get(); Rect bounds_get();
bool is_playing(const std::string& name); bool is_playing(const std::string& name);
bool is_finished(); bool is_finished();
bool is_group_visible(Anm2Animation& animation, int group, float frameTime, Affine& model, Color& tint,
Color& offset);
private: private:
void request_apply(const Request& request); 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);
}; };
+248
View File
@@ -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<Stage>& 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<Stage>& 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<Event>& 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);
}
+81 -71
View File
@@ -6,6 +6,11 @@ constexpr float FONT_SIZE = 20;
constexpr const char* EFFECT_NAMES[] = {"animation", "big", "color", "delay", "oscillation", "scene", constexpr const char* EFFECT_NAMES[] = {"animation", "big", "color", "delay", "oscillation", "scene",
"screenshake", "shake", "silent", "small", "sound"}; "screenshake", "shake", "silent", "small", "sound"};
constexpr const char* USE_NAMES[] = {"", "edible", "tool"}; constexpr const char* USE_NAMES[] = {"", "edible", "tool"};
constexpr const char* HOOK_NAMES[] = {
#define X(id, name) name,
HOOKS(X)
#undef X
};
struct SoundSource 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())]); 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); Entry* entry = pool_get(hooks[hook]);
if (found == hooks.end()) return entry ? entry : entry_get(hooks[hook]);
return nullptr; }
Entry* entry = pool_get(found->second);
return entry ? entry : entry_get(found->second); 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) 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"); description = json_string(root, "description");
credits = json_string(root, "credits"); credits = json_string(root, "credits");
color = json_color(root, "color", color); color = json_color(root, "color", color);
weight = json_float(root, "weight"); traits.weight = json_float(root, "weight");
anm2Path = json_string(root, "anm2"); anm2Path = json_string(root, "anm2");
portrait = root["portrait"].error() ? nullptr : renderer.texture_get(folder + json_string(root, "portrait")); 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")); 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) static void character_parse(object root, Character& character, Renderer& renderer, Audio& audio)
{ {
character.anm2 = character.anm2_get(character.anm2Path, renderer, audio); character.anm2 = character.anm2_get(character.anm2Path, renderer, audio);
character.weightMax = json_float(root, "weightMax", character.weight); Traits& traits = character.traits;
character.capacity = json_float(root, "capacity"); traits.weightMax = json_float(root, "weightMax", traits.weight);
character.capacityMin = json_float(root, "capacityMin"); traits.capacity = json_float(root, "capacity");
character.capacityMax = json_float(root, "capacityMax", character.capacity); traits.capacityMin = json_float(root, "capacityMin");
character.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1); traits.capacityMax = json_float(root, "capacityMax", traits.capacity);
character.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus"); traits.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1);
character.caloriesToKilogram = json_float(root, "caloriesToKilogram"); traits.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus");
character.digestionRate = json_float(root, "digestionRate") / TICK_RATE; traits.caloriesToKilogram = json_float(root, "caloriesToKilogram");
character.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE; traits.digestionRate = json_float(root, "digestionRate") / TICK_RATE;
character.digestionRateMax = json_float(root, "digestionRateMax", character.digestionRate * TICK_RATE) / TICK_RATE; traits.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE;
character.digestionTime = json_int(root, "digestionTime"); traits.digestionRateMax = json_float(root, "digestionRateMax", traits.digestionRate * TICK_RATE) / TICK_RATE;
character.blinkChance = json_float(root, "blinkChance"); traits.digestionTime = json_int(root, "digestionTime");
character.gurgleChance = json_float(root, "gurgleChance"); traits.blinkChance = json_float(root, "blinkChance");
character.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier"); traits.gurgleChance = json_float(root, "gurgleChance");
traits.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier");
character.dialoguePool = json_string(root, "dialoguePool"); character.dialoguePool = json_string(root, "dialoguePool");
object alternate = json_object(root, "alternate"); object alternate = json_object(root, "alternate");
if (!alternate["texture"].error()) 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()) if (!alternate["sound"].error())
character.alternateSound = audio.sound_get(character.folder + json_string(alternate, "sound")); character.alternate.sound = audio.sound_get(character.folder + json_string(alternate, "sound"));
character.alternateChance = json_float(alternate, "chance"); character.alternate.chance = json_float(alternate, "chance");
character.alternateSheet = json_int(alternate, "spritesheet"); character.alternate.sheet = json_int(alternate, "spritesheet");
object animations = json_object(root, "animations"); object animations = json_object(root, "animations");
character.animationStart = json_string(animations, "start"); character.animations = {json_string(animations, "idle"), json_string(animations, "idleFull"),
character.animationIdle = json_string(animations, "idle"); json_string(animations, "stageUp"), json_string(animations, "start")};
character.animationIdleFull = json_string(animations, "idleFull");
character.animationStageUp = json_string(animations, "stageUp");
Anm2* anm2 = character.anm2; Anm2* anm2 = character.anm2;
auto layer_get = [&](object source, const char* key) { 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) { auto null_get = [&](object source, const char* key) {
return anm2 ? anm2->null_get(json_string(source, key)) : -1; return anm2 ? anm2->null_get(json_string(source, key)) : -1;
}; };
character.talkSource = layer_get(json_object(root, "talk"), "source"); character.talk = {layer_get(json_object(root, "talk"), "destination"), layer_get(json_object(root, "talk"), "source")};
character.talkDestination = layer_get(json_object(root, "talk"), "destination"); character.blink = {layer_get(json_object(root, "blink"), "destination"),
character.blinkSource = layer_get(json_object(root, "blink"), "source"); layer_get(json_object(root, "blink"), "source")};
character.blinkDestination = layer_get(json_object(root, "blink"), "destination");
for (element entry : json_array(root, "eatAreas")) 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"); object cursor = json_object(root, "cursor");
character.cursor = character.anm2_get(json_string(cursor, "anm2"), renderer, audio);
object cursorAnimations = json_object(cursor, "animations"); object cursorAnimations = json_object(cursor, "animations");
character.cursorIdle = json_string(cursorAnimations, "idle"); auto cursor_get = [&](const char* key) {
character.cursorClick = json_string(cursorAnimations, "click"); return json_string(cursorAnimations, key);
character.cursorHover = json_string(cursorAnimations, "hover"); };
character.cursorGrab = json_string(cursorAnimations, "grab"); character.cursor = {cursor_get("click"),
character.cursorPan = json_string(cursorAnimations, "pan"); cursor_get("dispose"),
character.cursorZoom = json_string(cursorAnimations, "zoom"); cursor_get("grab"),
character.cursorDispose = json_string(cursorAnimations, "dispose"); cursor_get("hover"),
character.grabParticles = json_strings(json_object(cursor, "particles"), "grab"); 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"); object menu = json_object(root, "menu");
ImFontAtlas* fonts = ImGui::GetIO().Fonts; auto font_get = [&](const char* key) {
if (!menu["font"].error()) std::string path = json_string(menu, key);
character.font = fonts->AddFontFromFileTTF((character.folder + json_string(menu, "font")).c_str(), FONT_SIZE); return path.empty() ? nullptr
if (!menu["iconsFont"].error()) : ImGui::GetIO().Fonts->AddFontFromFileTTF((character.folder + path).c_str(), FONT_SIZE);
character.iconsFont = };
fonts->AddFontFromFileTTF((character.folder + json_string(menu, "iconsFont")).c_str(), FONT_SIZE); character.menu = {json_string(menu, "homeGlyph"), font_get("font"), font_get("iconsFont"),
character.rounding = json_float(menu, "rounding"); json_float(menu, "rounding")};
character.homeGlyph = json_string(menu, "homeGlyph");
for (auto [name, value] : json_object(root, "particles")) 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)] = { character.particles[std::string(name)] = {
json_string(fields, "animation"), character.anm2_get(json_string(fields, "anm2"), renderer, audio)}; 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) 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); Anm2* baseAnm2 = character.anm2_get(json_string(root, "anm2"), renderer, audio);
character.itemSheet = json_int(root, "spritesheet"); catalog.sheet = json_int(root, "spritesheet");
character.spawnDelay = json_int(root, "spawnDelay", character.spawnDelay); catalog.spawnDelay = json_int(root, "spawnDelay", catalog.spawnDelay);
character.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", character.spawnDelayMultiplier); catalog.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", catalog.spawnDelayMultiplier);
character.spawnLimit = json_int(root, "spawnLimit", character.spawnLimit); catalog.spawnLimit = json_int(root, "spawnLimit", catalog.spawnLimit);
object particles = json_object(root, "particles"); object particles = json_object(root, "particles");
character.disposeParticles = json_strings(particles, "dispose"); catalog.disposeParticles = json_strings(particles, "dispose");
character.summonParticles = json_strings(particles, "summon"); catalog.summonParticles = json_strings(particles, "summon");
object categories = json_object(root, "categories"); object categories = json_object(root, "categories");
std::vector<std::string> rarityNames; std::vector<std::string> rarityNames;
@@ -278,7 +290,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A
if (value.get(fields)) if (value.get(fields))
continue; continue;
std::string sound = json_string(fields, "sound"); 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")}); json_float(fields, "weight")});
rarityNames.emplace_back(name); rarityNames.emplace_back(name);
} }
@@ -288,7 +300,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A
object fields; object fields;
if (entry.get(fields)) if (entry.get(fields))
continue; continue;
Item& item = character.items.emplace_back(); Item& item = catalog.items.emplace_back();
item.name = json_string(fields, "name", "???"); item.name = json_string(fields, "name", "???");
item.description = json_string(fields, "description", "???"); item.description = json_string(fields, "description", "???");
item.category = json_string(fields, "category"); item.category = json_string(fields, "category");
@@ -323,12 +335,9 @@ static void dialogue_parse(object root, Character& character, Audio& audio)
if (!value.get(fields)) if (!value.get(fields))
dialogue.pauses[std::string(letter)] = {json_int(fields, "delay"), json_is(fields, "isSilent")}; dialogue.pauses[std::string(letter)] = {json_int(fields, "delay"), json_is(fields, "isSilent")};
} }
for (auto [hook, value] : json_object(root, "hooks")) object hooks = json_object(root, "hooks");
{ for (int i = 0; i < HOOK_COUNT; i++)
std::string_view target; dialogue.hooks[i] = json_string(hooks, HOOK_NAMES[i]);
if (!value.get(target))
dialogue.hooks[std::string(hook)] = target;
}
for (auto [pool, value] : json_object(root, "pools")) for (auto [pool, value] : json_object(root, "pools"))
{ {
array list; 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. // An actor for an item, showing its texture in place of its anm2's item spritesheet.
Actor Character::item_actor_get(int item) Actor Character::item_actor_get(int item)
{ {
Actor actor(items[item].anm2); Item& entry = catalog.items[item];
Actor actor(entry.anm2);
actor.isStatic = true; actor.isStatic = true;
if (items[item].texture && itemSheet >= 0 && itemSheet < (int)actor.sheets.size()) if (entry.texture && catalog.sheet >= 0 && catalog.sheet < (int)actor.sheets.size())
actor.sheets[itemSheet] = items[item].texture; actor.sheets[catalog.sheet] = entry.texture;
return actor; return actor;
} }
+125 -62
View File
@@ -1,7 +1,6 @@
#pragma once #pragma once
#include "anm2.h" #include "anm2.h"
#include "strings.h"
#include <array> #include <array>
#include <unordered_map> #include <unordered_map>
@@ -38,6 +37,32 @@ enum SoundId
SOUND_COUNT 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 struct Stage
{ {
std::string animationSuffix; std::string animationSuffix;
@@ -187,13 +212,12 @@ struct Pause
bool isSilent = false; bool isSilent = false;
}; };
// Dialogue entries by label, pools of them picked from at random, and hooks naming the pool (or else entry) the game // Dialogue entries by label, pools of them picked from at random, and the pool (or else entry) each hook plays.
// plays on an event.
struct Dialogue struct Dialogue
{ {
std::array<std::string, HOOK_COUNT> hooks;
std::unordered_map<std::string, Entry> entries; std::unordered_map<std::string, Entry> entries;
std::unordered_map<std::string, Pause> pauses; std::unordered_map<std::string, Pause> pauses;
std::unordered_map<std::string, std::string> hooks;
std::unordered_map<std::string, std::vector<std::string>> pools; std::unordered_map<std::string, std::vector<std::string>> pools;
Sounds blip; Sounds blip;
int blipDelay = 0; int blipDelay = 0;
@@ -201,59 +225,13 @@ struct Dialogue
Entry* entry_get(const std::string& label); Entry* entry_get(const std::string& label);
Entry* pool_get(const std::string& pool); 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 // The body a character starts with and the limits it grows within. Rates are percent per tick.
// played. struct Traits
class Character
{ {
public:
std::array<Sounds, SOUND_COUNT> sounds;
std::array<std::string, STRING_COUNT> 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<std::string, Anm2> anm2s;
std::unordered_map<std::string, Particle> particles;
std::vector<std::string> disposeParticles;
std::vector<std::string> grabParticles;
std::vector<std::string> summonParticles;
std::vector<Area> areas;
std::vector<EatArea> eatAreas;
std::vector<ExpandArea> expandAreas;
std::vector<InteractArea> interactAreas;
std::vector<InteractArea> interactTypes;
std::vector<Item> items;
std::vector<Rarity> rarities;
std::vector<Stage> 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 blinkChance = 0;
float caloriesToKilogram = 0; float caloriesToKilogram = 0;
float capacity = 0; float capacity = 0;
@@ -266,19 +244,104 @@ public:
float gurgleChance = 0; float gurgleChance = 0;
float overstuffedDigestBonus = 0; float overstuffedDigestBonus = 0;
float overstuffedMultiplier = 1; float overstuffedMultiplier = 1;
float rounding = 0;
float spawnDelayMultiplier = 1;
float weight = 0; float weight = 0;
float weightMax = 0; float weightMax = 0;
int alternateSheet = 0;
int blinkDestination = -1;
int blinkSource = -1;
int digestionTime = 0; 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<std::string> 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<std::string> disposeParticles;
std::vector<std::string> summonParticles;
std::vector<Item> items;
std::vector<Rarity> rarities;
float spawnDelayMultiplier = 1;
int sheet = 0;
int spawnDelay = 60; int spawnDelay = 60;
int spawnLimit = 100; 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, SOUND_COUNT> 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<std::string, Anm2> anm2s;
std::unordered_map<std::string, Particle> particles;
std::vector<Area> areas;
std::vector<EatArea> eatAreas;
std::vector<ExpandArea> expandAreas;
std::vector<InteractArea> interactAreas;
std::vector<InteractArea> interactTypes;
std::vector<Stage> 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 isLoaded = false;
bool isValid = false; bool isValid = false;
+24 -27
View File
@@ -5,14 +5,14 @@
constexpr float CONSOLE_HEIGHT = 0.4f; constexpr float CONSOLE_HEIGHT = 0.4f;
constexpr int INPUT_SIZE = 256; constexpr int INPUT_SIZE = 256;
struct Command struct ConsoleCommand
{ {
const char* name; const char* name;
const char* usage; const char* usage;
bool isPlayOnly; bool isPlayOnly;
}; };
constexpr Command COMMANDS[] = { constexpr ConsoleCommand COMMANDS[] = {
{"animations", "animations", true}, {"animations", "animations", true},
{"capacity", "capacity <calories>", true}, {"capacity", "capacity <calories>", true},
{"character", "character <name>", false}, {"character", "character <name>", false},
@@ -66,7 +66,7 @@ void Console::execute(Play* play, std::vector<Character>& characters)
inputs.push_back(input); inputs.push_back(input);
inputIndex = -1; inputIndex = -1;
history.push_back("> " + input); 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; return name == entry.name;
}); });
if (command == std::end(COMMANDS)) if (command == std::end(COMMANDS))
@@ -74,21 +74,18 @@ void Console::execute(Play* play, std::vector<Character>& characters)
if (command->isPlayOnly && !play) if (command->isPlayOnly && !play)
return history.push_back("Command unavailable outside game: " + name); return history.push_back("Command unavailable outside game: " + name);
float number = (float)SDL_atof(argument.c_str()); float number = (float)SDL_atof(argument.c_str());
Progress* progress = play ? &play->progress : nullptr; Body* body = play ? &play->body : nullptr;
if (name == "animations") if (name == "animations")
{ {
Anm2* anm2 = play->actor.anm2; Anm2* anm2 = body->actor.anm2;
for (int i = 0; anm2 && i < (int)anm2->animations.size(); i++) for (int i = 0; anm2 && i < (int)anm2->animations.size(); i++)
history.push_back(" " + std::to_string(i) + " " + anm2->animations[i].name); history.push_back(" " + std::to_string(i) + " " + anm2->animations[i].name);
} }
else if (name == "capacity") else if (name == "capacity")
progress->capacity = number; body->stats.capacity = number;
else if (name == "character" || name == "new_game") else if (name == "character" || name == "new_game")
{ commands->push_back({argument, name == "character" ? COMMAND_CONTINUE : COMMAND_NEW_GAME});
action = name == "character" ? ACTION_CHARACTER : ACTION_NEW_GAME;
target = argument;
}
else if (name == "characters") else if (name == "characters")
{ {
for (Character& character : characters) for (Character& character : characters)
@@ -101,53 +98,53 @@ void Console::execute(Play* play, std::vector<Character>& characters)
} }
else if (name == "digest") else if (name == "digest")
{ {
progress->isDigesting = true; body->stats.isDigesting = true;
progress->digestionTimer = 1; body->stats.digestionTimer = 1;
} }
else if (name == "digestion") else if (name == "digestion")
progress->digestionRate = number / TICK_RATE; body->stats.digestionRate = number / TICK_RATE;
else if (name == "help") else if (name == "help")
{ {
for (const Command& entry : COMMANDS) for (const ConsoleCommand& entry : COMMANDS)
if (play || !entry.isPlayOnly) if (play || !entry.isPlayOnly)
history.push_back(std::string(" ") + entry.usage); history.push_back(std::string(" ") + entry.usage);
} }
else if (name == "items") else if (name == "items")
{ {
for (int i = 0; i < (int)play->character->items.size(); i++) std::vector<Item>& items = play->character->catalog.items;
history.push_back(" " + std::to_string(i) + " " + play->character->items[i].name); for (int i = 0; i < (int)items.size(); i++)
history.push_back(" " + std::to_string(i) + " " + items[i].name);
} }
else if (name == "menu") else if (name == "menu")
action = ACTION_MENU; commands->push_back({"", COMMAND_MENU});
else if (name == "play_animation") 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") else if (name == "play_dialogue")
play->text.set(*play, play->character->dialogue.entry_get(argument)); play->text.set(*play, play->character->dialogue.entry_get(argument));
else if (name == "reload") else if (name == "reload")
action = ACTION_RELOAD; commands->push_back({"", COMMAND_RELOAD});
else if (name == "show_layers") else if (name == "show_layers")
Actor::isShowLayers = is_toggled(argument, Actor::isShowLayers); renderer->isShowLayers = is_toggled(argument, renderer->isShowLayers);
else if (name == "show_nulls") else if (name == "show_nulls")
Actor::isShowNulls = is_toggled(argument, Actor::isShowNulls); renderer->isShowNulls = is_toggled(argument, renderer->isShowNulls);
else if (name == "spawn") else if (name == "spawn")
{ {
int id = SDL_atoi(argument.c_str()); 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 + "."); history.push_back("Unable to spawn item " + argument + ".");
else else
play->spawn_queue_add(id); play->items.spawn_queue_add(*play, id);
} }
else if (name == "stage" || name == "weight") else if (name == "stage" || name == "weight")
{ {
play->stage = name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : play->stage_get(number); body->stage_set(name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : body->stage_get(number));
progress->weight = name == "stage" ? play->stage_threshold_get(play->stage) : number; body->stats.weight = name == "stage" ? body->stage_threshold_get(body->stage) : number;
play->idle_play(); body->idle_play();
} }
} }
void Console::update(Play* play, std::vector<Character>& characters) void Console::update(Play* play, std::vector<Character>& characters)
{ {
action = ACTION_NONE;
if (!isOpen) if (!isOpen)
return; return;
ImVec2 screen = ImGui::GetIO().DisplaySize; ImVec2 screen = ImGui::GetIO().DisplaySize;
+4 -12
View File
@@ -1,26 +1,18 @@
#pragma once #pragma once
#include "play.h" #include "play.h"
#include "ui.h"
enum ConsoleAction
{
ACTION_NONE,
ACTION_CHARACTER,
ACTION_MENU,
ACTION_NEW_GAME,
ACTION_RELOAD
};
// A developer console (opened with the grave key when "console=1" is in settings.txt) for inspecting and changing a // 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 class Console
{ {
public: public:
std::vector<std::string> history; std::vector<std::string> history;
std::vector<std::string> inputs; std::vector<std::string> inputs;
std::string input; std::string input;
std::string target; Commands* commands = nullptr;
ConsoleAction action = ACTION_NONE; Renderer* renderer = nullptr;
int inputIndex = -1; int inputIndex = -1;
bool isOpen = false; bool isOpen = false;
+82 -263
View File
@@ -1,151 +1,33 @@
#include "dialogue.h" #include "dialogue.h"
#include "play.h" #include "play.h"
#include "ui.h"
#include <cfloat>
#include <imgui_internal.h>
constexpr float LIFE_MAX = 10; 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 float SCREENSHAKE_MAGNITUDE = 0.03f;
constexpr int SCREENSHAKE_TIME = 15; 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) static bool is_silent(const Entry& entry, int at)
{ {
for (const Effect& effect : entry.effects) 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 true;
return false; return false;
} }
static float hash_unit(Uint32 value) static bool is_letter_start(const std::string& text, size_t at)
{ {
value ^= value >> 16; return at >= text.size() || ((unsigned char)text[at] & 0xC0) != 0x80;
value *= 0x7feb352du;
value ^= value >> 15;
value *= 0x846ca68bu;
value ^= value >> 16;
return (value & 0xffffu) / 65535.0f;
} }
// The scale, color and offset the entry's effects give its character at an index. // The UTF-8 character at an index of a text, or empty past its end.
static float letter_style_get(const Entry& entry, int at, float time, ImU32& color, ImVec2& offset) static std::string letter_get(const std::string& text, int at)
{ {
float scale = 1; size_t start = 0;
color = ImGui::GetColorU32(ImGuiCol_Text); while (start < text.size() && at > 0)
offset = {}; at -= is_letter_start(text, ++start);
for (const Effect& effect : entry.effects) size_t end = std::min(start + 1, text.size());
{ while (!is_letter_start(text, end))
if (!is_effect_on(effect, at)) end++;
continue; return text.substr(start, end - start);
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});
} }
void Text::set(Play& play, Entry* next, TextMode mode, const std::string& onEnd) 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 Interrupt interrupt = mode == TEXT_SEQUENCE ? INTERRUPT_ALWAYS
: mode == TEXT_INTERRUPTIBLE ? INTERRUPT_IF_ALLOWED : mode == TEXT_INTERRUPTIBLE ? INTERRUPT_IF_ALLOWED
: INTERRUPT_NEVER; : INTERRUPT_NEVER;
if (!next->animation.empty()) play.body.play(next->animation, TRANSITION_NORMAL, interrupt);
play.body_play(next->animation, TRANSITION_NORMAL, interrupt); delay = delayBase = play.character->dialogue.delay;
delayBase = play.character->dialogue.delay;
delay = delayBase;
for (Effect& effect : next->effects) for (Effect& effect : next->effects)
{ {
if (effect.start != 0) if (effect.start != 0)
continue; continue;
if (effect.type == EFFECT_DELAY) if (effect.type == EFFECT_DELAY)
delay = delayBase = std::max(1, effect.delay); delay = delayBase = std::max(1, effect.delay);
if (effect.type == EFFECT_ANIMATION && !effect.animation.empty()) if (effect.type == EFFECT_ANIMATION)
play.body_play(effect.animation, TRANSITION_FORCE); play.body.play(effect.animation, TRANSITION_FORCE);
} }
if (!next->text.empty()) if (!next->text.empty())
play.isTalking = !is_silent(*next, 0); play.body.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);
} }
// Applies the effects coming up at a character: delays and animations, screen shakes and sounds.
void Text::effects_apply(Play& play, int at) void Text::effects_apply(Play& play, int at)
{ {
for (Effect& effect : entry->effects) 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) void Text::clear(Play& play)
{ {
if (!idleOnEnd.empty()) if (!idleOnEnd.empty())
{ {
play.idle_override_set(idleOnEnd); play.body.idle_override_set(idleOnEnd);
play.idle_play(); play.body.idle_play();
idleOnEnd.clear(); idleOnEnd.clear();
} }
entry = nullptr; entry = nullptr;
isScene = false; isScene = false;
play.isTalking = false; play.body.isTalking = false;
} }
bool Text::is_interruptible() bool Text::is_interruptible()
@@ -224,37 +102,65 @@ bool Text::is_interruptible()
return !entry || entry->isTerminal; 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) if (!entry)
return; return;
Dialogue& dialogue = play.character->dialogue; Dialogue& dialogue = play.character->dialogue;
if (!isFinished)
{
if (!isStarted) if (!isStarted)
{ {
isStarted = true; isStarted = true;
for (Effect& effect : entry->effects) for (Effect& effect : entry->effects)
if (effect.start == 0 && (effect.type == EFFECT_SCREENSHAKE || effect.type == EFFECT_SOUND)) if (effect.start == 0 && effect.type == EFFECT_SCREENSHAKE)
effect_apply(play, *this, effect); play.camera.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME);
else if (effect.start == 0 && effect.type == EFFECT_SOUND)
play.audio->play(effect.sound);
} }
if (++tick >= delay) if (!isFinished && ++tick >= delay)
{ {
tick = 0; tick = 0;
index++; index++;
delay = delayBase; delay = delayBase;
effects_apply(play, index); effects_apply(play, index);
const char* letter = entry->text.c_str(); auto pause = dialogue.pauses.find(letter_get(entry->text, index - 1));
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; Pause found = pause == dialogue.pauses.end() ? Pause{} : pause->second;
bool isSilentEffect = is_silent(*entry, index - 1); bool isSilentEffect = is_silent(*entry, index - 1);
delay += found.delay; delay += found.delay;
play.isTalking = !found.isSilent && !isSilentEffect; play.body.isTalking = !found.isSilent && !isSilentEffect;
bool isBlipStep = dialogue.blipDelay > 0 && index % dialogue.blipDelay == 0; bool isBlipStep = dialogue.blipDelay > 0 && index % dialogue.blipDelay == 0;
if (isSilentEffect) if (isSilentEffect)
isBlipQueued = false; isBlipQueued = false;
@@ -269,112 +175,25 @@ void Text::update(Play& play, Vec2 screenSize)
{ {
isFinished = true; isFinished = true;
isBlipQueued = false; isBlipQueued = false;
play.isTalking = false; play.body.isTalking = false;
isScene &= !entry->isTerminal; isScene &= !entry->isTerminal;
} }
} }
}
if (entry->text.empty()) if (entry->text.empty())
{ {
Anm2Animation* animation = play.actor.animation_get(); Actor& actor = play.body.actor;
bool isReady = entry->animation.empty() || !animation || !play.actor.isPlaying || Anm2Animation* animation = actor.animation_get();
bool isReady = entry->animation.empty() || !animation || !actor.isPlaying ||
(!isAnimationStarted && animation->length <= 1) || (!isAnimationStarted && animation->length <= 1) ||
(animation->isLoop && isAnimationStarted && play.actor.time < animationTimePrevious); (animation->isLoop && isAnimationStarted && actor.time < animationTimePrevious);
isAnimationStarted = true; isAnimationStarted = true;
animationTimePrevious = play.actor.time; animationTimePrevious = actor.time;
if (entry->next.empty()) if (entry->next.empty())
clear(play); clear(play);
else if (isReady) else if (isReady)
set(play, dialogue.entry_get(entry->next), TEXT_SEQUENCE); set(play, dialogue.entry_get(entry->next), TEXT_SEQUENCE);
return;
} }
else if (isFinished && entry->isTerminal && (life += TICK) > LIFE_MAX)
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)
clear(play); clear(play);
} }
+4 -2
View File
@@ -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. // 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 class Text
{ {
public: public:
@@ -31,7 +31,9 @@ public:
bool isStarted = false; bool isStarted = false;
void set(Play& play, Entry* next, TextMode mode = TEXT_INTERRUPTIBLE, const std::string& onEnd = ""); 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); void clear(Play& play);
bool is_interruptible(); bool is_interruptible();
+289
View File
@@ -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<Item>& catalog = play.character->catalog.items;
std::vector<ItemEntity>& 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<Item>& 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;
}
+166 -442
View File
@@ -1,139 +1,123 @@
#include "play.h" #include "play.h"
#include <imgui.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_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 HOLD_SPIN = 0.1f;
constexpr float ROTATION_GROUND_DAMPING = 0.85f; constexpr float ROTATION_GROUND_DAMPING = 0.85f;
constexpr float ROTATION_MAX = 90; constexpr float ROTATION_MAX = 90;
constexpr float ROTATION_RETURN_SPEED = 120; constexpr float ROTATION_RETURN_SPEED = 120;
constexpr float THROW_THRESHOLD = 10; 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.position = position;
entity.actor.overrides.push_back({.rotation = rotation, .target = TARGET_ROOT, .isRotation = true}); entity.actor.rotation = rotation;
discovered[id] = true; 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) static float spawn_weight_get(Play& play, int id)
{ {
Item& item = play.character->items[id]; Catalog& catalog = play.character->catalog;
if (item.calories > play.capacity_max_get() || item.rarity < 0) Item& item = catalog.items[id];
if (item.calories > play.body.capacity_max_get() || item.rarity < 0)
return 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; return false;
for (int i = 0; i < (int)character->items.size(); i++) for (int i = 0; i < (int)catalog.items.size(); i++)
if (spawn_weight_get(*this, i) > 0) if (spawn_weight_get(play, i) > 0)
return true; return true;
return false; 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; return;
Item& item = character->items[id]; play.audio->play(play.character->sounds[SOUND_SUMMON]);
audio->play(character->sounds[SOUND_SUMMON]); if (catalog.items[id].rarity >= 0)
if (item.rarity >= 0) play.audio->play(catalog.rarities[catalog.items[id].rarity].sound);
audio->play(character->rarities[item.rarity].sound);
spawnQueue.push_back(id); 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. // 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; return;
std::vector<Item>& items = play.character->catalog.items;
float total = 0; float total = 0;
for (int i = 0; i < (int)character->items.size(); i++) for (int i = 0; i < (int)items.size(); i++)
total += spawn_weight_get(*this, i); total += spawn_weight_get(play, i);
float roll = random_get(0, total); float roll = random_get(0, total);
for (int i = 0; i < (int)character->items.size(); i++) for (int i = 0; i < (int)items.size(); i++)
if (float weight = spawn_weight_get(*this, i); weight > 0 && (roll -= weight) < 0) if (float weight = spawn_weight_get(play, i); weight > 0 && (roll -= weight) < 0)
return spawn_queue_add(i); 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. void Items::update(Play& play, Pointer pointer)
Rect Play::eat_rect_get(const EatArea& eatArea)
{ {
Rect rect = actor.null_rect_get(eatArea.null); Character& character = *play.character;
if (!is_rect_valid(rect)) Body& body = play.body;
rect = actor.null_rect_get(eatArea.null, actor.anm2->animation_get(eatArea.animation + suffix_get()), 0); Stats& stats = body.stats;
return rect; Actor& active = play.pointer_actor_get();
} Audio& audio = *play.audio;
int areaIndex = character.area_get(body.stage);
// Forgets which item is being eaten, and lets go of the held one when asked. Area area = areaIndex >= 0 ? character.areas[areaIndex] : Area{};
void Play::item_drop(Hold hold) Rect bounds = play.bounds;
{ bool isOver = body.is_over_capacity();
heldItem = hold == HOLD_RELEASE ? -1 : heldItem; auto text_play = [&](Hook hook) {
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) {
if (!pointer.isAutomated) 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) { auto release = [&](bool isThrown) {
item_drop(); drop();
active.play(data.cursorIdle); active.play(character.cursor.idle);
if (!isThrown && !actor.pending) if (!isThrown && !body.actor.pending)
idle_play(); body.idle_play();
}; };
if (text.isScene && heldItem >= 0) if (play.text.isScene && held >= 0)
{ {
held_release(false); release(false);
isEatTextPending = 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")); play.text.set(play, character.dialogue.hook_get(feeding.isTextFull ? HOOK_EAT_FULL : HOOK_EAT));
isEatTextPending = false; feeding.isTextPending = false;
eatAnimation = -1; feeding.animation = -1;
} }
bool wasItemHovered = isItemHovered; bool wasHovered = isHovered;
isItemHovered = false; isHovered = false;
tooltipItem = -1; tooltip = -1;
if (spawnDelay > 0) if (spawnDelay > 0)
spawnDelay--; spawnDelay--;
for (int id : spawnQueue) 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)}; Vec2 position = {spawn.x + random_get(0, spawn.w), spawn.y + random_get(0, spawn.h)};
if (!discovered[id]) if (!discovered[id])
{ {
toast_add(STRING_ToastNewItemFound); play.toast_add("New item found!");
audio->play(data.sounds[SOUND_NEW_ITEM]); audio.play(character.sounds[SOUND_NEW_ITEM]);
} }
item_add(id, position); add(play, id, position);
progress.totalSpawned++; stats.totalSpawned++;
particles_spawn(data.summonParticles, position); play.particles_spawn(character.catalog.summonParticles, position);
} }
spawnQueue.clear(); spawnQueue.clear();
if (heldItem >= 0 && heldItem < (int)items.size()) if (held >= 0 && held < (int)list.size())
{ {
ItemEntity& held = items[heldItem]; ItemEntity& entity = list[held];
Item& item = data.items[held.id]; Item& item = character.catalog.items[entity.id];
float& rotation = held.rotation_get(); float& rotation = *entity.actor.rotation;
Vec2 delta = itemPointerPrevious - pointer.position; Vec2 delta = pointerPrevious - pointer.position;
held.actor.position = pointer.position; entity.actor.position = pointer.position;
held.velocity = {}; entity.velocity = {};
held.angularVelocity = (held.angularVelocity - delta.x * HOLD_SPIN) * area.friction; entity.angularVelocity = (entity.angularVelocity - delta.x * HOLD_SPIN) * area.friction;
rotation = std::clamp(rotation * area.friction, -ROTATION_MAX, ROTATION_MAX); rotation = std::clamp(rotation * area.friction, -ROTATION_MAX, ROTATION_MAX);
int removed = -1; int removed = -1;
if (item.use == USE_EDIBLE) if (item.use == USE_EDIBLE)
{ {
bool isEdible = item.calories > 0 && progress.calories + item.calories <= capacity_max_get(); bool isEdible = item.calories > 0 && stats.calories + item.calories <= body.capacity_max_get();
if (isJustItemHeld) if (isJustHeld)
text_play(isEdible ? (isOver ? "FeedFull" : "Feed") text_play(isEdible ? (isOver ? HOOK_FEED_FULL : HOOK_FEED)
: item.calories > progress.capacity ? "LowCapacity" : item.calories > stats.capacity ? HOOK_LOW_CAPACITY
: "Full"); : HOOK_FULL);
isJustItemHeld = false; isJustHeld = false;
for (EatArea& eatArea : data.eatAreas) for (EatArea& eatArea : character.eatAreas)
{ {
Rect rect = eat_rect_get(eatArea); Rect rect = body.eat_rect_get(eatArea);
bool isInside = is_point_in_rect(rect, held.actor.position); bool isInside = is_point_in_rect(rect, entity.actor.position);
bool isAreaActive = isEdible && isInside; bool isAreaActive = isEdible && isInside;
bool isPending = eatItem == heldItem && eatEvent == eatArea.event; bool isPending = feeding.item == held && feeding.event == eatArea.event;
bool isPendingActive = isPending && is_point_in_rect(eatRect, held.actor.position); bool isPendingActive = isPending && is_point_in_rect(feeding.rect, entity.actor.position);
std::string eatName = eatArea.animation + suffix_get(); std::string eatName = eatArea.animation + body.suffix;
if (isAreaActive || isPendingActive) 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; Traits& traits = character.traits;
progress.calories += item.calories; body.actor.event = -1;
progress.totalCalories += item.calories; stats.calories += item.calories;
progress.capacity = std::clamp(progress.capacity + item.capacityBonus, data.capacityMin, stats.totalCalories += item.calories;
data.capacityMax); stats.capacity =
progress.digestionRate = std::clamp(progress.digestionRate + item.digestionBonus, std::clamp(stats.capacity + item.capacityBonus, traits.capacityMin, traits.capacityMax);
data.digestionRateMin, data.digestionRateMax); stats.digestionRate = std::clamp(stats.digestionRate + item.digestionBonus,
progress.totalConsumed++; traits.digestionRateMin, traits.digestionRateMax);
removed = heldItem; stats.totalConsumed++;
item_drop(); removed = held;
eatAnimation = actor.animation; drop();
isEatTextPending = !pointer.isAutomated; feeding.animation = body.actor.animation;
isEatTextFull = isOver; feeding.isTextPending = !pointer.isAutomated;
feeding.isTextFull = isOver;
break; break;
} }
eatItem = heldItem; feeding.item = held;
eatEvent = eatArea.event; feeding.event = eatArea.event;
if (isAreaActive) if (isAreaActive)
eatRect = rect; feeding.rect = rect;
if (!actor.is_playing(eatName)) if (!body.actor.is_playing(eatName))
body_play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS); body.play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS);
} }
else if (eatItem == heldItem) else if (feeding.item == held)
item_drop(HOLD_KEEP); drop(HOLD_KEEP);
if (actor.is_playing(eatName) && !isInside) if (body.actor.is_playing(eatName) && !isInside)
text_play(isOver ? "FoodTakenFull" : "FoodTaken"); text_play(isOver ? HOOK_FOOD_TAKEN_FULL : HOOK_FOOD_TAKEN);
if (isAreaActive) if (isAreaActive)
break; break;
} }
} }
else if (item.use == USE_TOOL && item.isToggleSpritesheet && else if (item.use == USE_TOOL && item.isToggleSpritesheet &&
is_point_in_rect(actor.bounds_get(), held.actor.position) && data.alternateTexture && is_point_in_rect(body.actor.bounds_get(), entity.actor.position) && character.alternate.texture &&
data.alternateSheet < (int)actor.sheets.size()) character.alternate.sheet < (int)body.actor.sheets.size())
{ {
progress.isAlternate = !progress.isAlternate; stats.isAlternate = !stats.isAlternate;
actor.sheets[data.alternateSheet] = body.actor.sheets[character.alternate.sheet] =
progress.isAlternate ? data.alternateTexture : data.anm2->sheets[data.alternateSheet]; stats.isAlternate ? character.alternate.texture : character.anm2->sheets[character.alternate.sheet];
audio->play(data.alternateSound); audio.play(character.alternate.sound);
removed = heldItem; removed = held;
item_drop(); drop();
} }
if (removed < 0 && pointer.isReleased) if (removed < 0 && pointer.isReleased)
{ {
bool isThrown = std::abs(delta.x) >= THROW_THRESHOLD || std::abs(delta.y) >= THROW_THRESHOLD; 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) if (isThrown)
text_play("Throw"); text_play(HOOK_THROW);
held.velocity = held.velocity - delta; entity.velocity = entity.velocity - delta;
held_release(isThrown); release(isThrown);
} }
if (removed >= 0) if (removed >= 0)
items.erase(items.begin() + removed); list.erase(list.begin() + removed);
} }
int moved = -1; 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 right = bounds.x + bounds.w;
float bottom = bounds.y + bounds.h; 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& position = entity.actor.position;
Vec2& velocity = entity.velocity; Vec2& velocity = entity.velocity;
float& rotation = entity.rotation_get(); float& rotation = *entity.actor.rotation;
float gravity = data.items[entity.id].gravity != 0 ? data.items[entity.id].gravity : area.gravity; float gravity = character.catalog.items[entity.id].gravity;
entity.actor.update(*audio); gravity = gravity != 0 ? gravity : area.gravity;
entity.actor.update(audio);
position = position + velocity; position = position + velocity;
rotation += entity.angularVelocity; rotation += entity.angularVelocity;
if (is_point_in_rect(entity.actor.bounds_get(), pointer.position) && !pointer.isCaptured && if (is_point_in_rect(entity.actor.bounds_get(), pointer.position) && !pointer.isCaptured &&
(!isTargetLocked || i == pointer.target)) (!isTargetLocked || i == pointer.target))
{ {
isItemHovered = true; isHovered = true;
tooltipItem = heldItem == i || pointer.isAutomated ? -1 : entity.id; tooltip = held == i || pointer.isAutomated ? -1 : entity.id;
active.play(data.cursorHover, TRANSITION_IF_IDLE); active.play(character.cursor.hover, TRANSITION_IF_IDLE);
cursorState = CURSOR_HOVER; play.cursorState = CURSOR_HOVER;
if (pointer.isClicked) if (pointer.isClicked)
{ {
audio->play(data.sounds[SOUND_GRAB]); audio.play(character.sounds[SOUND_GRAB]);
particles_spawn(data.grabParticles, pointer.position); play.particles_spawn(character.cursor.grabParticles, pointer.position);
isJustItemHeld = true; isJustHeld = true;
} }
if (pointer.isDown) if (pointer.isDown)
{ {
active.play(data.cursorGrab); active.play(character.cursor.grab);
cursorState = CURSOR_ACTION; play.cursorState = CURSOR_ACTION;
heldItem = moved = i; held = moved = i;
} }
if (pointer.isRightClicked) if (pointer.isRightClicked)
{ {
audio->play(data.sounds[SOUND_DISPOSE]); audio.play(character.sounds[SOUND_DISPOSE]);
active.play(data.cursorDispose, TRANSITION_FORCE, INTERRUPT_NEVER); active.play(character.cursor.dispose, TRANSITION_FORCE, INTERRUPT_NEVER);
particles_spawn(data.disposeParticles, position); play.particles_spawn(character.catalog.disposeParticles, position);
heldItem = heldItem == i ? -1 : heldItem > i ? heldItem - 1 : heldItem; held = held == i ? -1 : held > i ? held - 1 : held;
moved = moved == i ? -1 : moved > i ? moved - 1 : moved; moved = moved == i ? -1 : moved > i ? moved - 1 : moved;
items.erase(items.begin() + i--); list.erase(list.begin() + i--);
continue; continue;
} }
} }
if (i != heldItem) if (i != held)
{ {
if (position.x <= bounds.x || position.x >= right) if (position.x <= bounds.x || position.x >= right)
{ {
@@ -292,7 +278,7 @@ void Play::items_update(Pointer pointer)
velocity.x = -velocity.x * area.friction; velocity.x = -velocity.x * area.friction;
entity.angularVelocity = -entity.angularVelocity * area.friction; entity.angularVelocity = -entity.angularVelocity * area.friction;
rotation = -rotation; rotation = -rotation;
audio->play(data.sounds[SOUND_BOUNCE]); audio.play(character.sounds[SOUND_BOUNCE]);
} }
if (position.y <= bounds.y || position.y >= bottom) if (position.y <= bounds.y || position.y >= bottom)
{ {
@@ -301,7 +287,7 @@ void Play::items_update(Pointer pointer)
position.y = bottom; position.y = bottom;
velocity = {velocity.x * area.friction, 0}; velocity = {velocity.x * area.friction, 0};
rotation = std::remainder(rotation, 360.0f); 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); rotation = std::abs(rotation) <= step ? 0 : rotation - std::copysign(step, rotation);
entity.angularVelocity *= ROTATION_GROUND_DAMPING; entity.angularVelocity *= ROTATION_GROUND_DAMPING;
} }
@@ -309,7 +295,7 @@ void Play::items_update(Pointer pointer)
{ {
velocity.y = -velocity.y; velocity.y = -velocity.y;
entity.angularVelocity *= area.friction; entity.angularVelocity *= area.friction;
audio->play(data.sounds[SOUND_BOUNCE]); audio.play(character.sounds[SOUND_BOUNCE]);
} }
velocity.y *= area.friction; 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)}; 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]); ItemEntity entity = std::move(list[moved]);
items.erase(items.begin() + moved); list.erase(list.begin() + moved);
items.push_back(std::move(entity)); list.push_back(std::move(entity));
heldItem = (int)items.size() - 1; held = (int)list.size() - 1;
} }
if (wasItemHovered && !isItemHovered) if (wasHovered && !isHovered)
active.play(data.cursorIdle); active.play(character.cursor.idle);
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();
pointerPrevious = pointer.position; 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;
} }
+103 -98
View File
@@ -3,7 +3,6 @@
#include "ui.h" #include "ui.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3/SDL_main.h> #include <SDL3/SDL_main.h>
#include <utility>
#include <imgui_impl_opengl3.h> #include <imgui_impl_opengl3.h>
#include <imgui_impl_sdl3.h> #include <imgui_impl_sdl3.h>
@@ -29,11 +28,13 @@ enum Screen
SCREEN_PLAY 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 struct App
{ {
std::vector<Character> characters; std::vector<Character> characters;
Audio audio; Audio audio;
Commands commands;
Console console; Console console;
Play play; Play play;
Renderer renderer; Renderer renderer;
@@ -44,55 +45,54 @@ struct App
SDL_Window* window = nullptr; SDL_Window* window = nullptr;
Uint64 tickPrevious = 0; Uint64 tickPrevious = 0;
Screen screen = SCREEN_SELECT; Screen screen = SCREEN_SELECT;
bool isCursorHidden = false;
bool isRunning = true; bool isRunning = true;
}; };
static App game; static App app;
static int character_get(const std::string& key) static int character_get(const std::string& key)
{ {
for (int i = 0; i < (int)game.characters.size(); i++) for (int i = 0; i < (int)app.characters.size(); i++)
if (game.characters[i].key == key) if (app.characters[i].key == key)
return i; return i;
return -1; return -1;
} }
static void play_exit() static void play_exit()
{ {
if (game.screen != SCREEN_PLAY) if (app.screen != SCREEN_PLAY)
return; return;
game.play.save(); app.play.save();
game.settings.lastCharacter.clear(); app.settings.lastCharacter.clear();
ImGui::GetIO().FontDefault = game.font; ImGui::GetIO().FontDefault = app.font;
style_set(game.settings.color); style_set(app.settings.color);
widget_sounds_set(nullptr, nullptr, nullptr); app.screen = SCREEN_SELECT;
game.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) if (index < 0)
return; return;
play_exit(); play_exit();
Character& character = game.characters[index]; Character& character = app.characters[index];
game.ui.characterIndex = index; app.ui.characterIndex = index;
game.play.set(character, mode, game.renderer, game.audio); app.play.set(character, game, app.renderer, app.audio);
game.settings.lastCharacter = character.key; app.settings.lastCharacter = character.key;
ImGui::GetIO().FontDefault = character.font ? character.font : game.font; ImGui::GetIO().FontDefault = character.menu.font ? character.menu.font : app.font;
style_set(game.settings.isCharacterColor ? character.color : game.settings.color, character.rounding); style_set(app.settings.isCharacterColor ? character.color : app.settings.color, character.menu.rounding);
widget_sounds_set(&game.audio, &character.sounds[SOUND_HOVER], &character.sounds[SOUND_SELECT]); app.screen = SCREEN_PLAY;
game.ui.itemActors.clear();
game.ui.selectedItem = -1;
game.screen = SCREEN_PLAY;
} }
static void characters_reload() 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(); play_exit();
game.ui.previewActor = {}; app.ui.previewActor = {};
game.characters = characters_load(game.renderer); app.ui.catalogCharacter = nullptr;
play_start(character_get(key), GAME_CONTINUE); app.characters = characters_load(app.renderer);
play_start(key, GAME_CONTINUE);
} }
static void init() static void init()
@@ -100,29 +100,29 @@ static void init()
if (!SDL_Init(SDL_INIT_VIDEO)) if (!SDL_Init(SDL_INIT_VIDEO))
{ {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError()); SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
game.isRunning = false; app.isRunning = false;
return; return;
} }
game.settings.load(); app.settings.load();
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, GL_MAJOR); 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_MINOR_VERSION, GL_MINOR);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, GL_PROFILE); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, GL_PROFILE);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
game.window = SDL_CreateWindow(GAME_NAME, game.settings.width, game.settings.height, app.window =
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE); SDL_CreateWindow(GAME_NAME, app.settings.width, app.settings.height, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);
SDL_SetWindowPosition(game.window, game.settings.x, game.settings.y); SDL_SetWindowPosition(app.window, app.settings.x, app.settings.y);
game.context = game.window ? SDL_GL_CreateContext(game.window) : nullptr; app.context = app.window ? SDL_GL_CreateContext(app.window) : nullptr;
if (game.context) if (app.context)
game.renderer.init(); app.renderer.init();
if (!game.renderer.isValid) if (!app.renderer.isValid)
{ {
SDL_Log("Failed to initialize OpenGL: %s", SDL_GetError()); SDL_Log("Failed to initialize OpenGL: %s", SDL_GetError());
game.isRunning = false; app.isRunning = false;
return; return;
} }
SDL_GL_SetSwapInterval(1); SDL_GL_SetSwapInterval(1);
game.audio.init(); app.audio.init();
game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT); app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT);
ImGui::CreateContext(); ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
@@ -131,14 +131,19 @@ static void init()
io.KeyRepeatDelay = KEY_REPEAT_DELAY; io.KeyRepeatDelay = KEY_REPEAT_DELAY;
io.KeyRepeatRate = KEY_REPEAT_RATE; io.KeyRepeatRate = KEY_REPEAT_RATE;
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
ImGui_ImplSDL3_InitForOpenGL(game.window, game.context); ImGui_ImplSDL3_InitForOpenGL(app.window, app.context);
ImGui_ImplOpenGL3_Init(GLSL_VERSION); ImGui_ImplOpenGL3_Init(GLSL_VERSION);
game.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL); app.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL);
io.FontDefault = game.font; io.FontDefault = app.font;
style_set(game.settings.color); style_set(app.settings.color);
game.characters = characters_load(game.renderer); app.ui.audio = &app.audio;
play_start(character_get(game.settings.lastCharacter), GAME_CONTINUE); 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() static void events_handle()
@@ -148,94 +153,94 @@ static void events_handle()
{ {
ImGui_ImplSDL3_ProcessEvent(&event); ImGui_ImplSDL3_ProcessEvent(&event);
if (event.type == SDL_EVENT_QUIT) if (event.type == SDL_EVENT_QUIT)
game.isRunning = false; app.isRunning = false;
if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat)
continue; continue;
bool isTyping = ImGui::GetIO().WantTextInput; bool isTyping = ImGui::GetIO().WantTextInput;
bool isAltEnter = event.key.key == SDLK_RETURN && (event.key.mod & SDL_KMOD_ALT); 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)) 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) if (!isTyping && event.key.scancode == SDL_SCANCODE_M)
{ {
game.settings.isMuted = !game.settings.isMuted; app.settings.isMuted = !app.settings.isMuted;
game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT); app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT);
} }
if (event.key.scancode == SDL_SCANCODE_GRAVE && game.settings.isConsole) if (event.key.scancode == SDL_SCANCODE_GRAVE && app.settings.isConsole)
game.console.isOpen = !game.console.isOpen; 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() static void update()
{ {
Play& play = game.play; ImVec2 display = ImGui::GetIO().DisplaySize;
Ui& ui = game.ui; if (app.screen == SCREEN_SELECT)
Vec2 screen = {ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y}; app.ui.select_update(app.characters, app.settings);
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;
}
else else
{ {
ui.play_update(play, game.settings, game.renderer, game.audio); app.ui.play_update(app.play, app.settings);
play.update(screen); app.play.update({display.x, display.y});
if (std::exchange(ui.isSave, false))
play.save();
if (std::exchange(ui.isReturn, false))
play_exit();
} }
app.console.update(app.screen == SCREEN_PLAY ? &app.play : nullptr, app.characters);
commands_handle();
Console& console = game.console; app.ui.isFullscreen = SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN;
console.update(game.screen == SCREEN_PLAY ? &play : nullptr, game.characters); if (!app.ui.isFullscreen)
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)
{ {
SDL_GetWindowSize(game.window, &game.settings.width, &game.settings.height); SDL_GetWindowSize(app.window, &app.settings.width, &app.settings.height);
SDL_GetWindowPosition(game.window, &game.settings.x, &game.settings.y); SDL_GetWindowPosition(app.window, &app.settings.x, &app.settings.y);
} }
bool isCursorHidden = game.screen == SCREEN_PLAY && play.character->cursor && !console.isOpen; bool isCursorHidden = app.screen == SCREEN_PLAY && app.play.cursor.anm2 && !app.console.isOpen;
if (isCursorHidden != app.isCursorHidden)
isCursorHidden ? SDL_HideCursor() : SDL_ShowCursor(); isCursorHidden ? SDL_HideCursor() : SDL_ShowCursor();
app.isCursorHidden = isCursorHidden;
} }
static void render() static void render()
{ {
Play& play = game.play; Play& play = app.play;
Renderer& renderer = game.renderer; Renderer& renderer = app.renderer;
bool isPlaying = game.screen == SCREEN_PLAY; bool isPlaying = app.screen == SCREEN_PLAY;
ImVec2 screen = ImGui::GetIO().DisplaySize; 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); renderer.canvas_set(nullptr, (int)screen.x, (int)screen.y, background);
if (isPlaying) if (isPlaying)
play.render(renderer, {screen.x, screen.y}); play.render(renderer, {screen.x, screen.y});
renderer.flush(); renderer.flush();
ImGui::Render(); ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (isPlaying && play.character->cursor && !game.console.isOpen) if (app.isCursorHidden)
play.cursor.render(renderer, {}); play.cursor.render(renderer, {});
renderer.flush(); renderer.flush();
SDL_GL_SwapWindow(game.window); SDL_GL_SwapWindow(app.window);
} }
// Runs a tick (sixty a second) once its time comes. // Runs a tick (sixty a second) once its time comes.
static void loop() static void loop()
{ {
Uint64 now = SDL_GetTicks(); Uint64 now = SDL_GetTicks();
if (now - game.tickPrevious < TICK_MILLISECONDS) if (now - app.tickPrevious < TICK_MILLISECONDS)
return SDL_Delay(1); return SDL_Delay(1);
game.tickPrevious = now; app.tickPrevious = now;
events_handle(); events_handle();
ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL3_NewFrame(); ImGui_ImplSDL3_NewFrame();
@@ -247,24 +252,24 @@ static void loop()
static void quit() static void quit()
{ {
play_exit(); play_exit();
game.settings.save(); app.settings.save();
ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL3_Shutdown(); ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext(); ImGui::DestroyContext();
SDL_GL_DestroyContext(game.context); SDL_GL_DestroyContext(app.context);
SDL_DestroyWindow(game.window); SDL_DestroyWindow(app.window);
SDL_Quit(); SDL_Quit();
} }
int main(int, char**) int main(int, char**)
{ {
init(); init();
if (!game.isRunning) if (!app.isRunning)
return 1; return 1;
#ifdef SDL_PLATFORM_EMSCRIPTEN #ifdef SDL_PLATFORM_EMSCRIPTEN
emscripten_set_main_loop(loop, 0, true); emscripten_set_main_loop(loop, 0, true);
#else #else
while (game.isRunning) while (app.isRunning)
loop(); loop();
#endif #endif
quit(); quit();
+248 -460
View File
@@ -7,7 +7,10 @@ constexpr Rect WORLD_BOUNDS = {96, 54, 1728, 972};
constexpr ImGuiKey CHEAT_CODE[] = {ImGuiKey_UpArrow, ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_DownArrow, constexpr ImGuiKey CHEAT_CODE[] = {ImGuiKey_UpArrow, ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_DownArrow,
ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow,
ImGuiKey_B, ImGuiKey_A}; ImGuiKey_B, ImGuiKey_A};
constexpr const char* AUTOFEED_TYPE = "Rub"; 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 AUTOSAVE_TIME = 30;
constexpr float CHEAT_TIME = 5; constexpr float CHEAT_TIME = 5;
constexpr float FOCUS_MENU_WIDTH = 0.3f; 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_EPSILON = 0.1f;
constexpr float SHAKE_LERP = 0.72f; constexpr float SHAKE_LERP = 0.72f;
constexpr float SHAKE_MAGNITUDE = 0.1f; 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. struct SavedItem
static float value_clamp(float value, float low, float high)
{ {
if (low > high) std::string name;
std::swap(low, high); Vec2 position;
return std::isfinite(value) ? std::clamp(value, low, high) : low; Vec2 velocity;
} float rotation = 0;
};
static int zoom_index_get(float zoom) static int zoom_index_get(float zoom)
{ {
@@ -39,116 +38,99 @@ static int zoom_index_get(float zoom)
return index; 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) void Play::set(Character& played, Game game, Renderer& renderer, Audio& sound)
{ {
played.load(renderer, sound); played.load(renderer, sound);
*this = Play(); *this = Play();
character = &played; character = &played;
audio = &sound; audio = &sound;
camera.zoom = 0;
std::vector<std::string> found;
std::vector<SavedItem> saved;
if (game == GAME_CONTINUE) if (game == GAME_CONTINUE)
progress.load(played.save_path_get()); is_file_read(played.save_path_get(), [&](const std::string& key, const char* value) {
if (!progress.isValid) field_line_read(body.stats, key, value);
{ field_line_read(*this, key, value);
progress.weight = played.weight; SavedItem item;
progress.capacity = played.capacity; int length = 0;
progress.digestionRate = played.digestionRate; 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); Catalog& catalog = played.catalog;
actor.overrides.resize(OVERRIDE_EXPAND); items.discovered.assign(catalog.items.size(), false);
actor.overrides[OVERRIDE_TALK].id = played.talkDestination; for (const std::string& name : found)
actor.overrides[OVERRIDE_BLINK].id = played.blinkDestination; if (int id = catalog.item_get(name); id >= 0)
for (ExpandArea& expandArea : played.expandAreas) items.discovered[id] = true;
for (SavedItem& item : saved)
{ {
actor.overrides.push_back({.id = expandArea.layer}); int id = catalog.item_get(item.name);
actor.overrides.push_back({.id = expandArea.null, .target = TARGET_NULL}); 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; cursorType = played.interactTypes.empty() ? -1 : 0;
if (played.cursor) if (played.cursor.anm2)
{ {
cursor = Actor(played.cursor); cursor = Actor(played.cursor.anm2);
autoCursor = Actor(played.cursor); autoCursor = Actor(played.cursor.anm2);
autoCursor.isVisible = false; autoCursor.isVisible = false;
} }
isPostgame |= body.stage >= (int)played.stages.size();
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();
#ifdef DEBUG #ifdef DEBUG
isCheats = true; isCheats = true;
#endif #endif
isCheats |= isPostgame; isCheats |= isPostgame;
camera.isFocusPending = camera.zoom < ZOOM_LADDER[0];
int areaIndex = played.area_get(stage); camera.zoom = camera.isFocusPending ? ZOOM_LADDER[0] : ZOOM_LADDER[zoom_index_get(camera.zoom)];
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;
if (game == GAME_NEW) 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() void Play::save()
{ {
if (!character) if (!character)
return; return;
progress.pan = pan; std::string text = fields_write(body.stats) + fields_write(*this);
progress.zoom = zoom; for (int i = 0; i < (int)items.discovered.size(); i++)
progress.spawnDelay = spawnDelay; if (items.discovered[i])
progress.isPostgame = isPostgame; text += std::string(KEY_DISCOVERED) + "=" + character->catalog.items[i].name + "\n";
progress.items.clear(); for (ItemEntity& entity : items.list)
for (ItemEntity& entity : items) text += std::string(KEY_ITEM) + "=" + field_write(entity.actor.position) + " " + field_write(entity.velocity) +
progress.items.push_back( " " + field_write(entity.actor.rotation.value_or(0)) + " " + character->catalog.items[entity.id].name +
{character->items[entity.id].name, entity.actor.position, entity.velocity, entity.rotation_get()}); "\n";
progress.discovered.clear(); file_write(character->save_path_get(), text);
for (int i = 0; i < (int)discovered.size(); i++) toast_add("Saving...");
if (discovered[i])
progress.discovered.push_back(character->items[i].name);
progress.save(character->save_path_get());
toast_add(STRING_ToastSaving);
} }
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); toasts.push_back({message, TOAST_LIFE});
shakeTime = shakeTimeMax;
shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)};
} }
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() 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; 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) void Play::focus(Vec2 screenSize)
{ {
Rect rect = actor.bounds_get(); camera.focus(body.actor.bounds_get(), screenSize, focus_get());
if (!is_rect_valid(rect)) }
void Play::particles_spawn(const std::vector<std::string>& 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; return;
rect = rect_center(rect, FOCUS_PADDING); for (ImGuiKey key : CHEAT_KEYS)
float factor = std::min(screenSize.x / rect.w, screenSize.y / rect.h); {
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]))]; zoom = ZOOM_LADDER[zoom_index_get(std::clamp(factor * PERCENT, ZOOM_LADDER[0], std::end(ZOOM_LADDER)[-1]))];
factor = zoom / PERCENT; factor = zoom / PERCENT;
pan = rect_middle(rect) - screenSize / factor / 2; pan = rect_middle(target) - screenSize / factor / 2;
Focus panels = focus_get();
if (panels == FOCUS_MENU || panels == FOCUS_BOTH) if (panels == FOCUS_MENU || panels == FOCUS_BOTH)
pan.x += screenSize.x * FOCUS_MENU_WIDTH / factor / 2; pan.x += screenSize.x * FOCUS_MENU_WIDTH / factor / 2;
if (panels == FOCUS_TOOLS || panels == FOCUS_BOTH) if (panels == FOCUS_TOOLS || panels == FOCUS_BOTH)
pan.x -= screenSize.x * FOCUS_TOOLS_WIDTH / factor / 2; pan.x -= screenSize.x * FOCUS_TOOLS_WIDTH / factor / 2;
} }
float Play::capacity_max_get() // 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.
return progress.capacity * character->overstuffedMultiplier; void Camera::update(Play& play, Vec2 screenSize)
}
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<Stage>& 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<Stage>& 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)
{ {
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
Cursor& cursor = play.character->cursor;
if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive())
{ {
if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) ||
(ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl))) (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); pan = pan - Vec2{io.MouseDelta.x, io.MouseDelta.y} / (zoom / PERCENT);
} }
if (io.MouseWheel != 0) if (io.MouseWheel != 0)
{ {
Vec2 mouse = {io.MousePos.x, io.MousePos.y}; Vec2 mouse = {io.MousePos.x, io.MousePos.y};
Vec2 world = world_get(mouse); 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 index = std::clamp(zoom_index_get(zoom) + (io.MouseWheel > 0 ? 1 : -1), 0,
(int)std::size(ZOOM_LADDER) - 1); (int)std::size(ZOOM_LADDER) - 1);
zoom = ZOOM_LADDER[index]; zoom = ZOOM_LADDER[index];
@@ -413,7 +350,7 @@ void Play::camera_update(Vec2 screenSize)
} }
} }
if (ImGui::IsKeyPressed(ImGuiKey_Home)) if (ImGui::IsKeyPressed(ImGuiKey_Home))
focus(screenSize); play.focus(screenSize);
if (shakeTime > 0) if (shakeTime > 0)
{ {
@@ -430,152 +367,3 @@ void Play::camera_update(Vec2 screenSize)
shake = {}; 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<std::string>& 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);
}
+159 -94
View File
@@ -5,6 +5,7 @@
#include "save.h" #include "save.h"
constexpr float DIGESTION_MAX = 100; constexpr float DIGESTION_MAX = 100;
constexpr float TICK = 1 / TICK_RATE;
constexpr int TOAST_LIFE = 180; constexpr int TOAST_LIFE = 180;
constexpr float ZOOM_LADDER[] = {100, 115, 130, 150, 175, 200, 235, 275, 325, 400}; constexpr float ZOOM_LADDER[] = {100, 115, 130, 150, 175, 200, 235, 275, 325, 400};
@@ -14,12 +15,6 @@ enum Game
GAME_CONTINUE GAME_CONTINUE
}; };
enum Hold
{
HOLD_RELEASE,
HOLD_KEEP
};
enum CursorState enum CursorState
{ {
CURSOR_DEFAULT, CURSOR_DEFAULT,
@@ -36,18 +31,32 @@ enum Focus
FOCUS_BOTH 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. // The mouse as items and interact areas see it this tick: the player's, or the autofeed's stand-in.
struct Pointer struct Pointer
{ {
Vec2 position; Vec2 position;
int target = -1; int target = -1;
int type = -1; int type = -1;
bool isAutomated = false;
bool isCaptured = false; bool isCaptured = false;
bool isClicked = false; bool isClicked = false;
bool isDown = false; bool isDown = false;
bool isReleased = false; bool isReleased = false;
bool isRightClicked = false; bool isRightClicked = false;
bool isAutomated = false;
}; };
struct ItemEntity struct ItemEntity
@@ -56,11 +65,6 @@ struct ItemEntity
Vec2 velocity; Vec2 velocity;
float angularVelocity = 0; float angularVelocity = 0;
int id = 0; int id = 0;
float& rotation_get()
{
return actor.overrides[0].rotation;
}
}; };
struct Toast struct Toast
@@ -69,120 +73,181 @@ struct Toast
int life = 0; int life = 0;
}; };
class Renderer; class Play;
class Audio;
struct Settings;
// A character being played: its body (weight, digestion, stages), the items in the world, what the cursor does to // The character's body: its stats, digestion and stages, and the animations they choose.
// both, the autofeed, the camera and the effects. 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<Event>& 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<ItemEntity> list;
std::vector<int> spawnQueue;
std::vector<bool> 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<int> 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 class Play
{ {
public: public:
Character* character = nullptr; Character* character = nullptr;
Audio* audio = nullptr; Audio* audio = nullptr;
Progress progress; Autofeed autofeed;
Body body;
Camera camera;
Interactions interactions;
Items items;
Text text; Text text;
Actor actor;
Actor area; Actor area;
Actor autoCursor; Actor autoCursor;
Actor cursor; Actor cursor;
std::string idleOverride;
std::vector<Actor> particles; std::vector<Actor> particles;
std::vector<ItemEntity> items; std::vector<Event> events;
std::vector<Toast> toasts; std::vector<Toast> toasts;
std::vector<int> dialogueCooldowns;
std::vector<int> spawnQueue;
std::vector<bool> discovered;
Rect bounds; Rect bounds;
Vec2 autoVelocity;
Vec2 itemPointerPrevious;
Vec2 pan;
Vec2 pointerPrevious;
Vec2 shake;
float autoAngle = 0;
float autosaveTime = 0; float autosaveTime = 0;
float blinkTime = 0;
float cheatTime = 0; float cheatTime = 0;
float talkTime = 0;
float zoom = ZOOM_LADDER[0];
int autoClickCooldown = 0;
int autoTarget = -1;
int cheatIndex = 0; int cheatIndex = 0;
int cursorType = -1; 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; 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 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 isMenuOpen = true;
bool isPostgame = false; bool isPostgame = false;
bool isPostgamePending = false; bool isPostgamePending = false;
bool isStageUp = false;
bool isTalking = false;
bool isToolsOpen = false; bool isToolsOpen = false;
void set(Character& played, Game game, Renderer& renderer, Audio& sound); void set(Character& played, Game game, Renderer& renderer, Audio& sound);
void save(); void save();
void update(Vec2 screenSize); void update(Vec2 screenSize);
void render(Renderer& renderer, Vec2 screenSize); void render(Renderer& renderer, Vec2 screenSize);
void toast_add(StringId message);
void shake_add(float magnitude, int time);
void focus(Vec2 screenSize); void focus(Vec2 screenSize);
Vec2 world_get(Vec2 screen); void toast_add(const std::string& message);
void particles_spawn(const std::vector<std::string>& names, Vec2 position);
Actor& pointer_actor_get();
Focus focus_get(); Focus focus_get();
float capacity_max_get(); template <class Visitor> void visit(Visitor&& visitor)
float capacity_percent_get(); {
bool is_over_capacity(); visitor("pan", camera.pan);
int stage_get(float weight); visitor("postgame", isPostgame);
float stage_progress_get(); visitor("spawn_delay", items.spawnDelay);
float stage_threshold_get(int index); visitor("zoom", camera.zoom);
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<std::string>& names, Vec2 position);
Rect eat_rect_get(const EatArea& eatArea);
private: private:
void body_update(); void area_set();
void camera_update(Vec2 screenSize);
void cheats_update(); void cheats_update();
void interact_update(Pointer pointer); void events_handle(Vec2 screenSize);
void items_update(Pointer pointer);
void autofeed_update(Pointer& itemPointer, Pointer& interactPointer);
void item_drop(Hold hold = HOLD_RELEASE);
}; };
+2
View File
@@ -35,6 +35,8 @@ class Renderer
{ {
public: public:
Texture white; Texture white;
bool isShowLayers = false;
bool isShowNulls = false;
bool isValid = false; bool isValid = false;
void init(); void init();
+2 -174
View File
@@ -1,96 +1,13 @@
#include "save.h" #include "save.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdio>
#include <type_traits>
#ifdef SDL_PLATFORM_EMSCRIPTEN #ifdef SDL_PLATFORM_EMSCRIPTEN
#include <emscripten.h> #include <emscripten.h>
#endif #endif
constexpr const char* KEY_DISCOVERED = "discovered";
constexpr const char* KEY_ITEM = "item";
constexpr const char* SETTINGS_FILE = "settings.txt"; constexpr const char* SETTINGS_FILE = "settings.txt";
static void value_read(const char* text, float& value) void file_write(const std::string& path, const std::string& text)
{
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 <class Line> 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)
{ {
SDL_CreateDirectory(path.substr(0, path.find_last_of('/')).c_str()); SDL_CreateDirectory(path.substr(0, path.find_last_of('/')).c_str());
if (!SDL_SaveFile(path.c_str(), text.data(), text.size())) 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 #endif
} }
// Reads every field a visitable struct names from a key=value file, and writes them back.
template <class Fields> 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 <class Fields> static std::string fields_write(Fields& fields)
{
std::string text;
fields.visit([&](const char* name, auto& field) {
using Type = std::decay_t<decltype(field)>;
if constexpr (std::is_same_v<Type, bool> || std::is_enum_v<Type>)
text += std::string(name) + "=" + value_write((int)field) + "\n";
else
text += std::string(name) + "=" + value_write(field) + "\n";
});
return text;
}
template <class Visitor> 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() void Settings::load()
{ {
fields_read(pref_path_get(SETTINGS_FILE), *this); is_fields_read(pref_path_get(SETTINGS_FILE), *this);
} }
void Settings::save() void Settings::save()
@@ -159,53 +37,3 @@ void Settings::reset()
isCharacterColor = defaults.isCharacterColor; isCharacterColor = defaults.isCharacterColor;
isMuted = defaults.isMuted; isMuted = defaults.isMuted;
} }
template <class Visitor> 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);
}
+108 -22
View File
@@ -1,9 +1,14 @@
#pragma once #pragma once
#include "common.h" #include "common.h"
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_video.h> #include <SDL3/SDL_video.h>
#include <cstdio>
#include <string> #include <string>
#include <vector> #include <type_traits>
// 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 enum Measurement
{ {
@@ -11,6 +16,76 @@ enum Measurement
MEASUREMENT_IMPERIAL 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 <class Line> 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 <class Field> void field_read(const char* text, Field& field)
{
if constexpr (std::is_same_v<Field, std::string>)
field = text;
else if constexpr (std::is_same_v<Field, Vec2>)
std::sscanf(text, "%f %f", &field.x, &field.y);
else if constexpr (std::is_same_v<Field, Color>)
std::sscanf(text, "%f %f %f", &field.r, &field.g, &field.b);
else if constexpr (std::is_floating_point_v<Field>)
field = (Field)SDL_atof(text);
else
field = (Field)SDL_atoi(text);
}
template <class Field> std::string field_write(const Field& field)
{
if constexpr (std::is_same_v<Field, std::string>)
return field;
else if constexpr (std::is_same_v<Field, Vec2>)
return field_write(field.x) + " " + field_write(field.y);
else if constexpr (std::is_same_v<Field, Color>)
return field_write(field.r) + " " + field_write(field.g) + " " + field_write(field.b);
else if constexpr (std::is_floating_point_v<Field>)
return std::to_string(field);
else
return std::to_string((int)field);
}
// Sets whichever of a struct's fields a line names.
template <class Fields> 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 <class Fields> 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 <class Fields> 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. // The game's preferences, kept in settings.txt.
struct Settings struct Settings
{ {
@@ -29,23 +104,26 @@ struct Settings
void load(); void load();
void save(); void save();
void reset(); void reset();
template <class Visitor> void visit(Visitor&& visitor);
template <class Visitor> 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/<character>.txt, and the live state of one being played.
struct Progress
{
std::vector<std::string> discovered;
std::vector<SavedItem> items;
Vec2 pan;
float calories = 0; float calories = 0;
float capacity = 0; float capacity = 0;
float digestionProgress = 0; float digestionProgress = 0;
@@ -53,17 +131,25 @@ struct Progress
float playtime = 0; float playtime = 0;
float totalCalories = 0; float totalCalories = 0;
float weight = 0; float weight = 0;
float zoom = 0;
int digestionTimer = 0; int digestionTimer = 0;
int spawnDelay = 0;
int totalConsumed = 0; int totalConsumed = 0;
int totalSpawned = 0; int totalSpawned = 0;
bool isAlternate = false; bool isAlternate = false;
bool isDigesting = false; bool isDigesting = false;
bool isPostgame = false;
bool isValid = false;
void load(const std::string& path); template <class Visitor> void visit(Visitor&& visitor)
void save(const std::string& path); {
template <class Visitor> 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);
}
}; };
-106
View File
@@ -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
};
+608 -399
View File
File diff suppressed because it is too large. Load diff
+40 -29
View File
@@ -18,52 +18,63 @@ enum Direction
DIRECTION_RIGHT DIRECTION_RIGHT
}; };
// Hover and select sounds (from the character being played, or none) and the pulsing border on hovered widgets. enum CommandType
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)
{ {
widget_fx(); COMMAND_CONTINUE,
return result; 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<Command>;
void style_set(Color color, float rounding = 0); 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 class Ui
{ {
public: public:
std::vector<Actor> itemActors;
std::vector<Canvas> itemCanvases; std::vector<Canvas> itemCanvases;
std::vector<ImVec2> itemSizes;
Actor previewActor; Actor previewActor;
Canvas previewCanvas; 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 menuSlide = 1;
float toolsSlide = 0; float toolsSlide = 0;
int characterIndex = 0; int characterIndex = 0;
int progressIndex = -1; int selectedIndex = -1;
int selectedItem = -1; int selectedItem = -1;
bool isContinue = false;
bool isFullscreen = false; bool isFullscreen = false;
bool isFullscreenToggled = false;
bool isNewGame = false;
bool isNewGameWarning = false; bool isNewGameWarning = false;
bool isPreview = false; bool isSelectedSaved = false;
bool isRefresh = false;
bool isReturn = false;
bool isSave = false;
void play_update(Play& play, Settings& settings, Renderer& renderer, Audio& audio); void play_update(Play& play, Settings& settings);
void select_update(std::vector<Character>& characters, Settings& settings, Renderer& renderer, Audio& audio); void select_update(std::vector<Character>& characters, Settings& settings);
void settings_update(Settings& settings, Audio& audio, const Character* character);
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);
}; };