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
An object of particles by name, each with the `anm2` and `animation` it plays once where spawned.
### strings
Replaces UI text, by name. See [`src/strings.h`](src/strings.h) for every name and its default, e.g. `"strings": {"MenuTabInventory": "Pantry"}`.
# items.json
Has all items and their parameters, alongside additional behavior.
+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);
animations_parse(json_object(root, "animations"), animations, sheetRegions, layerSheets);
for (int i = 0; i < (int)animations.size(); i++)
animationIndices[animations[i].name] = i;
bounds.assign(animations.size(), {NAN, NAN, NAN, NAN});
defaultAnimation = std::max(0, animation_get(json_string(root, "defaultAnimation")));
}
@@ -185,10 +188,8 @@ static int name_get(const std::vector<std::string>& names, const std::string& na
int Anm2::animation_get(const std::string& name) const
{
for (int i = 0; i < (int)animations.size(); i++)
if (animations[i].name == name)
return i;
return -1;
auto found = animationIndices.find(name);
return found == animationIndices.end() ? -1 : found->second;
}
int Anm2::event_get(const std::string& name) const
@@ -230,11 +231,86 @@ static float ease(float t, Easing easing)
}
}
// The track's keyframe at a time, blended toward the next one if it eases. Past the end the last keyframe holds. An
// empty track gives an invisible frame.
Anm2Frame track_sample(const Anm2Track& track, float time)
{
Anm2Frame frame;
frame.isVisible = !track.empty();
time = std::max(time, 0.0f);
float start = 0;
for (size_t i = 0; i < track.size(); i++)
{
frame = track[i];
if (time >= start + frame.delay && i + 1 < track.size())
{
start += frame.delay;
continue;
}
if (frame.easing != EASING_NONE && i + 1 < track.size() && frame.delay > 1)
{
const Anm2Frame& next = track[i + 1];
float t = ease((time - start) / frame.delay, frame.easing);
frame.position = vec2_lerp(frame.position, next.position, t);
frame.scale = vec2_lerp(frame.scale, next.scale, t);
frame.rotation = std::lerp(frame.rotation, next.rotation, t);
frame.tint = color_lerp(frame.tint, next.tint, t);
frame.offset = color_lerp(frame.offset, next.offset, t);
}
break;
}
return frame;
}
static Affine root_affine_get(const Anm2Frame& frame, Vec2 position = {})
{
return affine_get(frame.position + position, frame.rotation, frame.scale / PERCENT);
}
static Affine layer_affine_get(const Anm2Frame& frame)
{
return affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot);
}
static Affine null_affine_get(const Anm2Frame& frame)
{
return affine_get(frame.position, frame.rotation, {1, 1}, frame.scale / 2);
}
// The smallest rectangle holding every visible layer over a whole animation, around its origin. Worked out once.
Rect Anm2::bounds_get(int index)
{
if (index < 0 || index >= (int)animations.size())
return {NAN, NAN, NAN, NAN};
if (is_rect_valid(bounds[index]))
return bounds[index];
Anm2Animation& animation = animations[index];
Actor actor(this, {}, animation.name);
for (int time = 0; time < animation.length; time++)
{
Affine root = root_affine_get(track_sample(animation.root, (float)time));
for (Anm2Item& layer : animation.layers)
{
Affine model = root;
Color tint;
Color offset;
Anm2Frame frame = track_sample(layer.frames, (float)time);
if (layer.isVisible && frame.isVisible && frame.size.x > 0 &&
actor.is_group_visible(animation, layer.group, (float)time, model, tint, offset))
bounds[index] = rect_merge(bounds[index], affine_bounds(model * layer_affine_get(frame), frame.size.x,
frame.size.y));
}
}
return bounds[index];
}
Actor::Actor(Anm2* anm2, Vec2 position, const std::string& name) : anm2(anm2), position(position)
{
if (!anm2)
return;
sheets = anm2->sheets;
layers.resize(anm2->layers.size());
nullScales.resize(anm2->nulls.size());
int index = anm2->animation_get(name);
request_apply({anm2->animations[index >= 0 ? index : anm2->defaultAnimation].name, TRANSITION_FORCE});
}
@@ -247,8 +323,7 @@ Anm2Animation* Actor::animation_get(int index)
bool Actor::is_playing(const std::string& name)
{
Anm2Animation* current = animation_get();
return isPlaying && current && current->name == name;
return isPlaying && anm2 && animation >= 0 && anm2->animation_get(name) == animation;
}
bool Actor::is_finished()
@@ -268,8 +343,7 @@ void Actor::request_apply(const Request& request)
int index = anm2 ? anm2->animation_get(request.animation) : -1;
if (index < 0)
{
static std::unordered_set<std::string> missing;
if (anm2 && missing.insert(request.animation).second)
if (anm2 && anm2->missing.insert(request.animation).second)
SDL_Log("Animation \"%s\" does not exist", request.animation.c_str());
return;
}
@@ -289,23 +363,20 @@ void Actor::request_apply(const Request& request)
triggersPlayed.assign(anm2->animations[index].triggers.size(), false);
}
void Actor::pulse_add(int layer, float amplitude, float cycles, float duration)
{
if (layer >= 0 && layer < (int)layers.size() && duration > 0)
pulses.push_back({amplitude, cycles, duration, duration, layer});
}
void Actor::update(Audio& audio)
{
if (pending)
request_apply(*pending);
pending.reset();
for (int i = 0; i < (int)overrides.size(); i++)
{
Override& override = overrides[i];
if (override.timeStart <= 0)
continue;
float percent = std::clamp(override.time / override.timeStart, 0.0f, 1.0f);
float wave = std::cos((1 - percent) * 2 * PI * override.cycles) * percent;
override.scale = {std::abs(override.amplitude.x) * wave, std::abs(override.amplitude.y) * wave};
if (--override.time <= 0)
overrides.erase(overrides.begin() + i--);
}
for (int i = 0; i < (int)pulses.size(); i++)
if (--pulses[i].time <= 0)
pulses.erase(pulses.begin() + i--);
Anm2Animation* current = animation_get();
if (!isPlaying || (current && current->isLoop))
@@ -339,62 +410,50 @@ void Actor::update(Audio& audio)
triggersPlayed.assign(triggersPlayed.size(), false);
}
// The track's keyframe at a time, blended toward the next one if it eases, then changed by any overrides. Past the end
// the last keyframe holds. An empty track gives an invisible frame.
Anm2Frame Actor::frame_get(const Anm2Track& track, float frameTime, OverrideTarget target, int id)
// A layer's keyframe at a time, with its swapped region, added scale and pulses.
Anm2Frame Actor::layer_frame_get(const Anm2Item& layer, float frameTime)
{
Anm2Frame frame;
if (track.empty())
{
frame.isVisible = false;
Anm2Frame frame = track_sample(layer.frames, frameTime);
if (layer.id < 0 || layer.id >= (int)layers.size())
return frame;
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);
float start = 0;
for (size_t i = 0; i < track.size(); i++)
frame.scale = frame.scale + change.scale;
for (Pulse& pulse : pulses)
{
frame = track[i];
if (frameTime >= start + frame.delay && i + 1 < track.size())
{
start += frame.delay;
if (pulse.layer != layer.id)
continue;
}
if (frame.easing != EASING_NONE && i + 1 < track.size() && frame.delay > 1)
{
const Anm2Frame& next = track[i + 1];
float t = ease((frameTime - start) / frame.delay, frame.easing);
frame.position = vec2_lerp(frame.position, next.position, t);
frame.scale = vec2_lerp(frame.scale, next.scale, t);
frame.rotation = std::lerp(frame.rotation, next.rotation, t);
frame.tint = color_lerp(frame.tint, next.tint, t);
frame.offset = color_lerp(frame.offset, next.offset, t);
}
break;
}
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;
float percent = pulse.time / pulse.duration;
float wave = std::abs(pulse.amplitude) * std::cos((1 - percent) * 2 * PI * pulse.cycles) * percent;
frame.scale = frame.scale + Vec2{wave, wave};
}
return frame;
}
static Affine root_affine_get(const Anm2Frame& frame, Vec2 position = {})
Anm2Frame Actor::null_frame_get(const Anm2Item& null, float frameTime)
{
return affine_get(frame.position + position, frame.rotation, frame.scale / PERCENT);
Anm2Frame frame = track_sample(null.frames, frameTime);
if (null.id >= 0 && null.id < (int)nullScales.size())
frame.scale = frame.scale + nullScales[null.id];
return frame;
}
Anm2Frame Actor::root_frame_get(Anm2Animation& current, float frameTime)
{
Anm2Frame frame = track_sample(current.root, frameTime);
if (rotation)
frame.rotation = *rotation;
return frame;
}
// The transform, tint and color offset a group (and its parents) give its items, or false if any is hidden.
bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime, Affine& model, Color& tint, Color& offset)
bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime, Affine& model, Color& tint,
Color& offset)
{
if (group < 0 || group >= (int)current.groups.size())
return true;
@@ -403,24 +462,19 @@ bool Actor::is_group_visible(Anm2Animation& current, int group, float frameTime,
return false;
if (entry.root.empty())
return true;
Anm2Frame frame = frame_get(entry.root, frameTime);
Anm2Frame frame = track_sample(entry.root, frameTime);
model = model * root_affine_get(frame);
tint = tint * frame.tint;
offset = offset + frame.offset;
return frame.isVisible;
}
static Affine null_affine_get(const Anm2Frame& frame)
{
return affine_get(frame.position, frame.rotation, {1, 1}, frame.scale / 2);
}
void Actor::render(Renderer& renderer, Affine view)
{
Anm2Animation* current = animation_get();
if (!isVisible || !current)
return;
Anm2Frame root = frame_get(current->root, time);
Anm2Frame root = root_frame_get(*current, time);
Affine rootModel = view * root_affine_get(root, position);
for (Anm2Item& layer : current->layers)
{
@@ -429,25 +483,25 @@ void Actor::render(Renderer& renderer, Affine view)
Color offset = root.offset;
if (!layer.isVisible || !is_group_visible(*current, layer.group, time, model, tint, offset))
continue;
Anm2Frame frame = frame_get(layer.frames, time, TARGET_LAYER, layer.id);
Anm2Frame frame = layer_frame_get(layer, time);
int sheet = layer.id >= 0 && layer.id < (int)anm2->layerSheets.size() ? anm2->layerSheets[layer.id] : -1;
Texture* texture = sheet >= 0 && sheet < (int)sheets.size() ? sheets[sheet] : nullptr;
if (!frame.isVisible || !texture)
continue;
model = model * affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot);
model = model * layer_affine_get(frame);
renderer.quad_draw(*texture, {frame.crop.x, frame.crop.y, frame.size.x, frame.size.y}, model,
frame.tint * tint, frame.offset + offset);
if (isShowLayers)
if (renderer.isShowLayers)
renderer.outline_draw(model, frame.size.x, frame.size.y, LAYER_COLOR);
}
if (!isShowNulls)
if (!renderer.isShowNulls)
return;
for (Anm2Item& null : current->nulls)
{
Affine model = rootModel;
Color tint;
Color offset;
Anm2Frame frame = frame_get(null.frames, time, TARGET_NULL, null.id);
Anm2Frame frame = null_frame_get(null, time);
if (null.isVisible && frame.isVisible && is_group_visible(*current, null.group, time, model, tint, offset))
renderer.outline_draw(model * null_affine_get(frame), frame.scale.x, frame.scale.y, NULL_COLOR);
}
@@ -464,10 +518,10 @@ Rect Actor::null_rect_get(int null, int index, float frameTime)
{
if (item.id != null)
continue;
Affine model = root_affine_get(frame_get(current->root, frameTime), position);
Affine model = root_affine_get(root_frame_get(*current, frameTime), position);
Color tint;
Color offset;
Anm2Frame frame = frame_get(item.frames, frameTime, TARGET_NULL, null);
Anm2Frame frame = null_frame_get(item, frameTime);
if (!frame.isVisible || !is_group_visible(*current, item.group, frameTime, model, tint, offset))
break;
return affine_bounds(model * null_affine_get(frame), frame.scale.x, frame.scale.y);
@@ -475,28 +529,12 @@ Rect Actor::null_rect_get(int null, int index, float frameTime)
return {NAN, NAN, NAN, NAN};
}
// The smallest rectangle holding every visible layer over the whole current animation.
// The current animation's bounds (see Anm2::bounds_get) where the actor stands, turned by its set rotation.
Rect Actor::bounds_get()
{
Rect bounds = {NAN, NAN, NAN, NAN};
Anm2Animation* current = animation_get();
if (!current)
return bounds;
for (int frameTime = 0; frameTime < current->length; frameTime++)
{
Affine rootModel = root_affine_get(frame_get(current->root, (float)frameTime), position);
for (Anm2Item& layer : current->layers)
{
Affine model = rootModel;
Color tint;
Color offset;
Anm2Frame frame = frame_get(layer.frames, (float)frameTime, TARGET_LAYER, layer.id);
if (!layer.isVisible || !frame.isVisible || frame.size.x <= 0 ||
!is_group_visible(*current, layer.group, (float)frameTime, model, tint, offset))
continue;
model = model * affine_get(frame.position, frame.rotation, frame.scale / PERCENT, frame.pivot);
bounds = rect_merge(bounds, affine_bounds(model, frame.size.x, frame.size.y));
}
}
return bounds;
Rect local = anm2 ? anm2->bounds_get(animation) : Rect{NAN, NAN, NAN, NAN};
if (!is_rect_valid(local))
return local;
Affine model = affine_get(position, rotation.value_or(0), {1, 1}) * Affine{1, 0, 0, 1, local.x, local.y};
return affine_bounds(model, local.w, local.h);
}
+31 -25
View File
@@ -4,6 +4,8 @@
#include "renderer.h"
#include <optional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
enum Easing
@@ -75,10 +77,13 @@ struct Anm2Animation
class Anm2
{
public:
std::unordered_map<std::string, int> animationIndices;
std::unordered_set<std::string> missing;
std::vector<Anm2Animation> animations;
std::vector<std::string> events;
std::vector<std::string> layers;
std::vector<std::string> nulls;
std::vector<Rect> bounds;
std::vector<Texture*> sheets;
std::vector<int> layerSheets;
Sounds sounds;
@@ -90,9 +95,11 @@ public:
int event_get(const std::string& name) const;
int layer_get(const std::string& name) const;
int null_get(const std::string& name) const;
Rect bounds_get(int animation);
};
int track_length_get(const Anm2Track& track);
Anm2Frame track_sample(const Anm2Track& track, float time);
enum Transition
{
@@ -108,30 +115,24 @@ enum Interrupt
INTERRUPT_NEVER
};
enum OverrideTarget
// How an actor changes one of its layers: a region shown in place of its own, and scale added on.
struct LayerChange
{
TARGET_ROOT,
TARGET_LAYER,
TARGET_NULL
};
// Changes one track's frames as an actor draws them: a swapped region, an added scale (oscillating out over time, when
// timed) or a set rotation.
struct Override
{
Vec2 amplitude;
Vec2 crop;
Vec2 pivot;
Vec2 scale;
Vec2 size;
float cycles = 0;
float rotation = 0;
float time = 0;
float timeStart = 0;
int id = -1;
OverrideTarget target = TARGET_LAYER;
bool isRegion = false;
bool isRotation = false;
};
// Scale added to a layer that oscillates and fades out over its duration, in ticks.
struct Pulse
{
float amplitude = 0;
float cycles = 1;
float duration = 0;
float time = 0;
int layer = -1;
};
struct Request
@@ -141,17 +142,18 @@ struct Request
Interrupt interrupt = INTERRUPT_IF_ALLOWED;
};
// Plays an Anm2's animations by name at a position. A static actor holds its animation's first frame.
// Plays an Anm2's animations by name at a position. A static actor holds its animation's first frame. Locked, it ignores
// requests to play.
class Actor
{
public:
static inline bool isShowLayers = false;
static inline bool isShowNulls = false;
Anm2* anm2 = nullptr;
std::optional<Request> pending;
std::vector<Override> overrides;
std::optional<float> rotation;
std::vector<LayerChange> layers;
std::vector<Pulse> pulses;
std::vector<Texture*> sheets;
std::vector<Vec2> nullScales;
std::vector<bool> triggersPlayed;
Vec2 position;
float time = 0;
@@ -167,16 +169,20 @@ public:
Actor(Anm2* anm2, Vec2 position = {}, const std::string& animation = "");
void play(const std::string& name, Transition transition = TRANSITION_NORMAL,
Interrupt interrupt = INTERRUPT_IF_ALLOWED);
void pulse_add(int layer, float amplitude, float cycles, float duration);
void update(Audio& audio);
void render(Renderer& renderer, Affine view);
Anm2Animation* animation_get(int index = -1);
Anm2Frame frame_get(const Anm2Track& track, float frameTime, OverrideTarget target = TARGET_ROOT, int id = -1);
Anm2Frame layer_frame_get(const Anm2Item& layer, float frameTime);
Rect null_rect_get(int null, int index = -1, float frameTime = -1);
Rect bounds_get();
bool is_playing(const std::string& name);
bool is_finished();
bool is_group_visible(Anm2Animation& animation, int group, float frameTime, Affine& model, Color& tint,
Color& offset);
private:
void request_apply(const Request& request);
bool is_group_visible(Anm2Animation& animation, int group, float frameTime, Affine& model, Color& tint, Color& offset);
Anm2Frame null_frame_get(const Anm2Item& null, float frameTime);
Anm2Frame root_frame_get(Anm2Animation& animation, float frameTime);
};
+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",
"screenshake", "shake", "silent", "small", "sound"};
constexpr const char* USE_NAMES[] = {"", "edible", "tool"};
constexpr const char* HOOK_NAMES[] = {
#define X(id, name) name,
HOOKS(X)
#undef X
};
struct SoundSource
{
@@ -42,13 +47,23 @@ Entry* Dialogue::pool_get(const std::string& pool)
return entry_get(found->second[SDL_rand((Sint32)found->second.size())]);
}
Entry* Dialogue::hook_get(const std::string& hook)
Entry* Dialogue::hook_get(Hook hook)
{
auto found = hooks.find(hook);
if (found == hooks.end())
return nullptr;
Entry* entry = pool_get(found->second);
return entry ? entry : entry_get(found->second);
Entry* entry = pool_get(hooks[hook]);
return entry ? entry : entry_get(hooks[hook]);
}
bool Dialogue::is_hook(Hook hook)
{
return pools.contains(hooks[hook]) || entries.contains(hooks[hook]);
}
int Catalog::item_get(const std::string& name)
{
for (int i = 0; i < (int)items.size(); i++)
if (items[i].name == name)
return i;
return -1;
}
void Character::preview_load(const std::string& path, Renderer& renderer)
@@ -65,7 +80,7 @@ void Character::preview_load(const std::string& path, Renderer& renderer)
description = json_string(root, "description");
credits = json_string(root, "credits");
color = json_color(root, "color", color);
weight = json_float(root, "weight");
traits.weight = json_float(root, "weight");
anm2Path = json_string(root, "anm2");
portrait = root["portrait"].error() ? nullptr : renderer.texture_get(folder + json_string(root, "portrait"));
render = root["render"].error() ? nullptr : renderer.texture_get(folder + json_string(root, "render"));
@@ -120,35 +135,34 @@ static void interact_parse(object source, InteractArea& area, Character& charact
static void character_parse(object root, Character& character, Renderer& renderer, Audio& audio)
{
character.anm2 = character.anm2_get(character.anm2Path, renderer, audio);
character.weightMax = json_float(root, "weightMax", character.weight);
character.capacity = json_float(root, "capacity");
character.capacityMin = json_float(root, "capacityMin");
character.capacityMax = json_float(root, "capacityMax", character.capacity);
character.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1);
character.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus");
character.caloriesToKilogram = json_float(root, "caloriesToKilogram");
character.digestionRate = json_float(root, "digestionRate") / TICK_RATE;
character.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE;
character.digestionRateMax = json_float(root, "digestionRateMax", character.digestionRate * TICK_RATE) / TICK_RATE;
character.digestionTime = json_int(root, "digestionTime");
character.blinkChance = json_float(root, "blinkChance");
character.gurgleChance = json_float(root, "gurgleChance");
character.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier");
Traits& traits = character.traits;
traits.weightMax = json_float(root, "weightMax", traits.weight);
traits.capacity = json_float(root, "capacity");
traits.capacityMin = json_float(root, "capacityMin");
traits.capacityMax = json_float(root, "capacityMax", traits.capacity);
traits.overstuffedMultiplier = json_float(root, "overstuffedMultiplier", 1);
traits.overstuffedDigestBonus = json_float(root, "overstuffedDigestBonus");
traits.caloriesToKilogram = json_float(root, "caloriesToKilogram");
traits.digestionRate = json_float(root, "digestionRate") / TICK_RATE;
traits.digestionRateMin = json_float(root, "digestionRateMin") / TICK_RATE;
traits.digestionRateMax = json_float(root, "digestionRateMax", traits.digestionRate * TICK_RATE) / TICK_RATE;
traits.digestionTime = json_int(root, "digestionTime");
traits.blinkChance = json_float(root, "blinkChance");
traits.gurgleChance = json_float(root, "gurgleChance");
traits.gurgleCapacityMultiplier = json_float(root, "gurgleCapacityMultiplier");
character.dialoguePool = json_string(root, "dialoguePool");
object alternate = json_object(root, "alternate");
if (!alternate["texture"].error())
character.alternateTexture = renderer.texture_get(character.folder + json_string(alternate, "texture"));
character.alternate.texture = renderer.texture_get(character.folder + json_string(alternate, "texture"));
if (!alternate["sound"].error())
character.alternateSound = audio.sound_get(character.folder + json_string(alternate, "sound"));
character.alternateChance = json_float(alternate, "chance");
character.alternateSheet = json_int(alternate, "spritesheet");
character.alternate.sound = audio.sound_get(character.folder + json_string(alternate, "sound"));
character.alternate.chance = json_float(alternate, "chance");
character.alternate.sheet = json_int(alternate, "spritesheet");
object animations = json_object(root, "animations");
character.animationStart = json_string(animations, "start");
character.animationIdle = json_string(animations, "idle");
character.animationIdleFull = json_string(animations, "idleFull");
character.animationStageUp = json_string(animations, "stageUp");
character.animations = {json_string(animations, "idle"), json_string(animations, "idleFull"),
json_string(animations, "stageUp"), json_string(animations, "start")};
Anm2* anm2 = character.anm2;
auto layer_get = [&](object source, const char* key) {
@@ -157,10 +171,9 @@ static void character_parse(object root, Character& character, Renderer& rendere
auto null_get = [&](object source, const char* key) {
return anm2 ? anm2->null_get(json_string(source, key)) : -1;
};
character.talkSource = layer_get(json_object(root, "talk"), "source");
character.talkDestination = layer_get(json_object(root, "talk"), "destination");
character.blinkSource = layer_get(json_object(root, "blink"), "source");
character.blinkDestination = layer_get(json_object(root, "blink"), "destination");
character.talk = {layer_get(json_object(root, "talk"), "destination"), layer_get(json_object(root, "talk"), "source")};
character.blink = {layer_get(json_object(root, "blink"), "destination"),
layer_get(json_object(root, "blink"), "source")};
for (element entry : json_array(root, "eatAreas"))
{
@@ -225,26 +238,28 @@ static void character_parse(object root, Character& character, Renderer& rendere
}
object cursor = json_object(root, "cursor");
character.cursor = character.anm2_get(json_string(cursor, "anm2"), renderer, audio);
object cursorAnimations = json_object(cursor, "animations");
character.cursorIdle = json_string(cursorAnimations, "idle");
character.cursorClick = json_string(cursorAnimations, "click");
character.cursorHover = json_string(cursorAnimations, "hover");
character.cursorGrab = json_string(cursorAnimations, "grab");
character.cursorPan = json_string(cursorAnimations, "pan");
character.cursorZoom = json_string(cursorAnimations, "zoom");
character.cursorDispose = json_string(cursorAnimations, "dispose");
character.grabParticles = json_strings(json_object(cursor, "particles"), "grab");
auto cursor_get = [&](const char* key) {
return json_string(cursorAnimations, key);
};
character.cursor = {cursor_get("click"),
cursor_get("dispose"),
cursor_get("grab"),
cursor_get("hover"),
cursor_get("idle"),
cursor_get("pan"),
cursor_get("zoom"),
json_strings(json_object(cursor, "particles"), "grab"),
character.anm2_get(json_string(cursor, "anm2"), renderer, audio)};
object menu = json_object(root, "menu");
ImFontAtlas* fonts = ImGui::GetIO().Fonts;
if (!menu["font"].error())
character.font = fonts->AddFontFromFileTTF((character.folder + json_string(menu, "font")).c_str(), FONT_SIZE);
if (!menu["iconsFont"].error())
character.iconsFont =
fonts->AddFontFromFileTTF((character.folder + json_string(menu, "iconsFont")).c_str(), FONT_SIZE);
character.rounding = json_float(menu, "rounding");
character.homeGlyph = json_string(menu, "homeGlyph");
auto font_get = [&](const char* key) {
std::string path = json_string(menu, key);
return path.empty() ? nullptr
: ImGui::GetIO().Fonts->AddFontFromFileTTF((character.folder + path).c_str(), FONT_SIZE);
};
character.menu = {json_string(menu, "homeGlyph"), font_get("font"), font_get("iconsFont"),
json_float(menu, "rounding")};
for (auto [name, value] : json_object(root, "particles"))
{
@@ -253,22 +268,19 @@ static void character_parse(object root, Character& character, Renderer& rendere
character.particles[std::string(name)] = {
json_string(fields, "animation"), character.anm2_get(json_string(fields, "anm2"), renderer, audio)};
}
object strings = json_object(root, "strings");
for (int i = 0; i < STRING_COUNT; i++)
character.strings[i] = json_string(strings, STRING_NAMES[i], STRING_DEFAULTS[i]);
}
static void items_parse(object root, Character& character, Renderer& renderer, Audio& audio)
{
Catalog& catalog = character.catalog;
Anm2* baseAnm2 = character.anm2_get(json_string(root, "anm2"), renderer, audio);
character.itemSheet = json_int(root, "spritesheet");
character.spawnDelay = json_int(root, "spawnDelay", character.spawnDelay);
character.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", character.spawnDelayMultiplier);
character.spawnLimit = json_int(root, "spawnLimit", character.spawnLimit);
catalog.sheet = json_int(root, "spritesheet");
catalog.spawnDelay = json_int(root, "spawnDelay", catalog.spawnDelay);
catalog.spawnDelayMultiplier = json_float(root, "spawnDelayMultiplier", catalog.spawnDelayMultiplier);
catalog.spawnLimit = json_int(root, "spawnLimit", catalog.spawnLimit);
object particles = json_object(root, "particles");
character.disposeParticles = json_strings(particles, "dispose");
character.summonParticles = json_strings(particles, "summon");
catalog.disposeParticles = json_strings(particles, "dispose");
catalog.summonParticles = json_strings(particles, "summon");
object categories = json_object(root, "categories");
std::vector<std::string> rarityNames;
@@ -278,7 +290,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A
if (value.get(fields))
continue;
std::string sound = json_string(fields, "sound");
character.rarities.push_back({std::string(name), sound.empty() ? nullptr : audio.sound_get(character.folder + sound),
catalog.rarities.push_back({std::string(name), sound.empty() ? nullptr : audio.sound_get(character.folder + sound),
json_float(fields, "weight")});
rarityNames.emplace_back(name);
}
@@ -288,7 +300,7 @@ static void items_parse(object root, Character& character, Renderer& renderer, A
object fields;
if (entry.get(fields))
continue;
Item& item = character.items.emplace_back();
Item& item = catalog.items.emplace_back();
item.name = json_string(fields, "name", "???");
item.description = json_string(fields, "description", "???");
item.category = json_string(fields, "category");
@@ -323,12 +335,9 @@ static void dialogue_parse(object root, Character& character, Audio& audio)
if (!value.get(fields))
dialogue.pauses[std::string(letter)] = {json_int(fields, "delay"), json_is(fields, "isSilent")};
}
for (auto [hook, value] : json_object(root, "hooks"))
{
std::string_view target;
if (!value.get(target))
dialogue.hooks[std::string(hook)] = target;
}
object hooks = json_object(root, "hooks");
for (int i = 0; i < HOOK_COUNT; i++)
dialogue.hooks[i] = json_string(hooks, HOOK_NAMES[i]);
for (auto [pool, value] : json_object(root, "pools"))
{
array list;
@@ -418,10 +427,11 @@ void Character::load(Renderer& renderer, Audio& audio)
// An actor for an item, showing its texture in place of its anm2's item spritesheet.
Actor Character::item_actor_get(int item)
{
Actor actor(items[item].anm2);
Item& entry = catalog.items[item];
Actor actor(entry.anm2);
actor.isStatic = true;
if (items[item].texture && itemSheet >= 0 && itemSheet < (int)actor.sheets.size())
actor.sheets[itemSheet] = items[item].texture;
if (entry.texture && catalog.sheet >= 0 && catalog.sheet < (int)actor.sheets.size())
actor.sheets[catalog.sheet] = entry.texture;
return actor;
}
+125 -62
View File
@@ -1,7 +1,6 @@
#pragma once
#include "anm2.h"
#include "strings.h"
#include <array>
#include <unordered_map>
@@ -38,6 +37,32 @@ enum SoundId
SOUND_COUNT
};
// The game events dialogue can hook onto, by their names in dialogue.json.
#define HOOKS(X) \
X(START, "Start") \
X(END, "End") \
X(HELP, "Help") \
X(RANDOM, "Random") \
X(STAGE_UP, "StageUp") \
X(DIGEST, "Digest") \
X(FEED, "Feed") \
X(FEED_FULL, "FeedFull") \
X(EAT, "Eat") \
X(EAT_FULL, "EatFull") \
X(FULL, "Full") \
X(LOW_CAPACITY, "LowCapacity") \
X(THROW, "Throw") \
X(FOOD_TAKEN, "FoodTaken") \
X(FOOD_TAKEN_FULL, "FoodTakenFull")
enum Hook
{
#define X(id, name) HOOK_##id,
HOOKS(X)
#undef X
HOOK_COUNT
};
struct Stage
{
std::string animationSuffix;
@@ -187,13 +212,12 @@ struct Pause
bool isSilent = false;
};
// Dialogue entries by label, pools of them picked from at random, and hooks naming the pool (or else entry) the game
// plays on an event.
// Dialogue entries by label, pools of them picked from at random, and the pool (or else entry) each hook plays.
struct Dialogue
{
std::array<std::string, HOOK_COUNT> hooks;
std::unordered_map<std::string, Entry> entries;
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;
Sounds blip;
int blipDelay = 0;
@@ -201,59 +225,13 @@ struct Dialogue
Entry* entry_get(const std::string& label);
Entry* pool_get(const std::string& pool);
Entry* hook_get(const std::string& hook);
Entry* hook_get(Hook hook);
bool is_hook(Hook hook);
};
// Everything a character folder holds. The preview (what the select screen shows) loads at startup, the rest when
// played.
class Character
// The body a character starts with and the limits it grows within. Rates are percent per tick.
struct Traits
{
public:
std::array<Sounds, 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 caloriesToKilogram = 0;
float capacity = 0;
@@ -266,19 +244,104 @@ public:
float gurgleChance = 0;
float overstuffedDigestBonus = 0;
float overstuffedMultiplier = 1;
float rounding = 0;
float spawnDelayMultiplier = 1;
float weight = 0;
float weightMax = 0;
int alternateSheet = 0;
int blinkDestination = -1;
int blinkSource = -1;
int digestionTime = 0;
int itemSheet = 0;
};
struct Alternate
{
Sound* sound = nullptr;
Texture* texture = nullptr;
float chance = 0;
int sheet = 0;
};
struct Animations
{
std::string idle;
std::string idleFull;
std::string stageUp;
std::string start;
};
// A layer whose region is shown on another: for talking and blinking.
struct Swap
{
int destination = -1;
int source = -1;
};
struct Cursor
{
std::string click;
std::string dispose;
std::string grab;
std::string hover;
std::string idle;
std::string pan;
std::string zoom;
std::vector<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 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 isValid = false;
+24 -27
View File
@@ -5,14 +5,14 @@
constexpr float CONSOLE_HEIGHT = 0.4f;
constexpr int INPUT_SIZE = 256;
struct Command
struct ConsoleCommand
{
const char* name;
const char* usage;
bool isPlayOnly;
};
constexpr Command COMMANDS[] = {
constexpr ConsoleCommand COMMANDS[] = {
{"animations", "animations", true},
{"capacity", "capacity <calories>", true},
{"character", "character <name>", false},
@@ -66,7 +66,7 @@ void Console::execute(Play* play, std::vector<Character>& characters)
inputs.push_back(input);
inputIndex = -1;
history.push_back("> " + input);
auto command = std::find_if(std::begin(COMMANDS), std::end(COMMANDS), [&](const Command& entry) {
auto command = std::find_if(std::begin(COMMANDS), std::end(COMMANDS), [&](const ConsoleCommand& entry) {
return name == entry.name;
});
if (command == std::end(COMMANDS))
@@ -74,21 +74,18 @@ void Console::execute(Play* play, std::vector<Character>& characters)
if (command->isPlayOnly && !play)
return history.push_back("Command unavailable outside game: " + name);
float number = (float)SDL_atof(argument.c_str());
Progress* progress = play ? &play->progress : nullptr;
Body* body = play ? &play->body : nullptr;
if (name == "animations")
{
Anm2* anm2 = play->actor.anm2;
Anm2* anm2 = body->actor.anm2;
for (int i = 0; anm2 && i < (int)anm2->animations.size(); i++)
history.push_back(" " + std::to_string(i) + " " + anm2->animations[i].name);
}
else if (name == "capacity")
progress->capacity = number;
body->stats.capacity = number;
else if (name == "character" || name == "new_game")
{
action = name == "character" ? ACTION_CHARACTER : ACTION_NEW_GAME;
target = argument;
}
commands->push_back({argument, name == "character" ? COMMAND_CONTINUE : COMMAND_NEW_GAME});
else if (name == "characters")
{
for (Character& character : characters)
@@ -101,53 +98,53 @@ void Console::execute(Play* play, std::vector<Character>& characters)
}
else if (name == "digest")
{
progress->isDigesting = true;
progress->digestionTimer = 1;
body->stats.isDigesting = true;
body->stats.digestionTimer = 1;
}
else if (name == "digestion")
progress->digestionRate = number / TICK_RATE;
body->stats.digestionRate = number / TICK_RATE;
else if (name == "help")
{
for (const Command& entry : COMMANDS)
for (const ConsoleCommand& entry : COMMANDS)
if (play || !entry.isPlayOnly)
history.push_back(std::string(" ") + entry.usage);
}
else if (name == "items")
{
for (int i = 0; i < (int)play->character->items.size(); i++)
history.push_back(" " + std::to_string(i) + " " + play->character->items[i].name);
std::vector<Item>& items = play->character->catalog.items;
for (int i = 0; i < (int)items.size(); i++)
history.push_back(" " + std::to_string(i) + " " + items[i].name);
}
else if (name == "menu")
action = ACTION_MENU;
commands->push_back({"", COMMAND_MENU});
else if (name == "play_animation")
play->actor.play(argument, TRANSITION_FORCE, INTERRUPT_ALWAYS);
body->actor.play(argument, TRANSITION_FORCE, INTERRUPT_ALWAYS);
else if (name == "play_dialogue")
play->text.set(*play, play->character->dialogue.entry_get(argument));
else if (name == "reload")
action = ACTION_RELOAD;
commands->push_back({"", COMMAND_RELOAD});
else if (name == "show_layers")
Actor::isShowLayers = is_toggled(argument, Actor::isShowLayers);
renderer->isShowLayers = is_toggled(argument, renderer->isShowLayers);
else if (name == "show_nulls")
Actor::isShowNulls = is_toggled(argument, Actor::isShowNulls);
renderer->isShowNulls = is_toggled(argument, renderer->isShowNulls);
else if (name == "spawn")
{
int id = SDL_atoi(argument.c_str());
if (argument.empty() || id < 0 || id >= (int)play->character->items.size())
if (argument.empty() || id < 0 || id >= (int)play->character->catalog.items.size())
history.push_back("Unable to spawn item " + argument + ".");
else
play->spawn_queue_add(id);
play->items.spawn_queue_add(*play, id);
}
else if (name == "stage" || name == "weight")
{
play->stage = name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : play->stage_get(number);
progress->weight = name == "stage" ? play->stage_threshold_get(play->stage) : number;
play->idle_play();
body->stage_set(name == "stage" ? std::max(0, SDL_atoi(argument.c_str()) - 1) : body->stage_get(number));
body->stats.weight = name == "stage" ? body->stage_threshold_get(body->stage) : number;
body->idle_play();
}
}
void Console::update(Play* play, std::vector<Character>& characters)
{
action = ACTION_NONE;
if (!isOpen)
return;
ImVec2 screen = ImGui::GetIO().DisplaySize;
+4 -12
View File
@@ -1,26 +1,18 @@
#pragma once
#include "play.h"
enum ConsoleAction
{
ACTION_NONE,
ACTION_CHARACTER,
ACTION_MENU,
ACTION_NEW_GAME,
ACTION_RELOAD
};
#include "ui.h"
// A developer console (opened with the grave key when "console=1" is in settings.txt) for inspecting and changing a
// game. Commands that change characters are left as an action for the game to carry out.
// game. Commands that change what's running go to the game's command list.
class Console
{
public:
std::vector<std::string> history;
std::vector<std::string> inputs;
std::string input;
std::string target;
ConsoleAction action = ACTION_NONE;
Commands* commands = nullptr;
Renderer* renderer = nullptr;
int inputIndex = -1;
bool isOpen = false;
+82 -263
View File
@@ -1,151 +1,33 @@
#include "dialogue.h"
#include "play.h"
#include "ui.h"
#include <cfloat>
#include <imgui_internal.h>
constexpr float LIFE_MAX = 10;
constexpr float OSCILLATION_AMPLITUDE = 2;
constexpr float OSCILLATION_FREQUENCY = 1;
constexpr float OSCILLATION_SPREAD = 0.16f;
constexpr float SCREENSHAKE_MAGNITUDE = 0.03f;
constexpr int SCREENSHAKE_TIME = 15;
constexpr float SHAKE_AMPLITUDE = 1;
constexpr float SHAKE_FREQUENCY = 30;
constexpr float TEXT_BIG = 1.25f;
constexpr float TEXT_SMALL = 0.75f;
constexpr float WIDTH_MULTIPLIER = 0.3f;
constexpr float HEIGHT_LINES = 6;
constexpr float INDICATOR_BOB = 0.12f;
constexpr float INDICATOR_SIZE = 0.35f;
constexpr float INDICATOR_SPEED = 2;
static bool is_effect_on(const Effect& effect, int at)
{
return effect.start <= at && at < effect.end;
}
static bool is_silent(const Entry& entry, int at)
{
for (const Effect& effect : entry.effects)
if (effect.type == EFFECT_SILENT && is_effect_on(effect, at))
if (effect.type == EFFECT_SILENT && effect.start <= at && at < effect.end)
return true;
return false;
}
static float hash_unit(Uint32 value)
static bool is_letter_start(const std::string& text, size_t at)
{
value ^= value >> 16;
value *= 0x7feb352du;
value ^= value >> 15;
value *= 0x846ca68bu;
value ^= value >> 16;
return (value & 0xffffu) / 65535.0f;
return at >= text.size() || ((unsigned char)text[at] & 0xC0) != 0x80;
}
// The scale, color and offset the entry's effects give its character at an index.
static float letter_style_get(const Entry& entry, int at, float time, ImU32& color, ImVec2& offset)
// The UTF-8 character at an index of a text, or empty past its end.
static std::string letter_get(const std::string& text, int at)
{
float scale = 1;
color = ImGui::GetColorU32(ImGuiCol_Text);
offset = {};
for (const Effect& effect : entry.effects)
{
if (!is_effect_on(effect, at))
continue;
Uint32 seed = (Uint32)std::floor(time * SHAKE_FREQUENCY) * 747796405u + (Uint32)at * 2891336453u;
switch (effect.type)
{
case EFFECT_COLOR:
color = ImGui::GetColorU32(imvec4_get({effect.color.r, effect.color.g, effect.color.b, 1}));
break;
case EFFECT_BIG:
scale = TEXT_BIG;
break;
case EFFECT_SMALL:
scale = TEXT_SMALL;
break;
case EFFECT_OSCILLATION:
offset.y += std::sin((time * OSCILLATION_FREQUENCY + at * OSCILLATION_SPREAD) * 2 * PI) *
OSCILLATION_AMPLITUDE;
break;
case EFFECT_SHAKE:
offset.x += (hash_unit(seed) * 2 - 1) * SHAKE_AMPLITUDE;
offset.y += (hash_unit(seed ^ 0xa511e9b3u) * 2 - 1) * SHAKE_AMPLITUDE;
break;
default:
break;
}
}
return scale;
}
// Draws the entry's first count characters with their effects, wrapping whole words to the window.
static void letters_draw(const Entry& entry, int count, float time)
{
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
ImVec2 start = ImGui::GetCursorScreenPos();
float wrap = start.x + ImGui::GetContentRegionAvail().x;
ImVec2 cursor = start;
float lineHeight = fontSize;
const char* end = entry.text.data() + entry.text.size();
auto width_get = [&](const char* from, const char* to, int at) {
ImU32 color;
ImVec2 offset;
return font->CalcTextSizeA(fontSize * letter_style_get(entry, at, time, color, offset), FLT_MAX, 0, from, to).x;
};
auto line_break = [&]() {
cursor = {start.x, cursor.y + lineHeight};
lineHeight = fontSize;
};
int at = 0;
for (const char* it = entry.text.data(); it < end && at < count; at++)
{
unsigned int codepoint = 0;
const char* next = it + ImTextCharFromUtf8(&codepoint, it, end);
if (codepoint == '\n')
{
line_break();
it = next;
continue;
}
bool isSpace = codepoint == ' ';
if (!isSpace && cursor.x > start.x && it[-1] == ' ')
{
float wordWidth = 0;
int wordAt = at;
for (const char* letter = it; letter < end && *letter != ' ' && *letter != '\n'; wordAt++)
{
unsigned int wordCodepoint = 0;
const char* letterNext = letter + ImTextCharFromUtf8(&wordCodepoint, letter, end);
wordWidth += width_get(letter, letterNext, wordAt);
letter = letterNext;
}
if (cursor.x + wordWidth > wrap && wordWidth <= wrap - start.x)
line_break();
}
ImU32 color;
ImVec2 offset;
float scale = letter_style_get(entry, at, time, color, offset);
float width = width_get(it, next, at);
if (cursor.x + width > wrap)
{
line_break();
if (isSpace)
{
it = next;
continue;
}
}
ImGui::GetWindowDrawList()->AddText(font, fontSize * scale, {cursor.x + offset.x, cursor.y + offset.y}, color,
it, next);
cursor.x += width;
lineHeight = std::max(lineHeight, fontSize * scale);
it = next;
}
ImGui::Dummy({wrap - start.x, cursor.y + lineHeight - start.y});
size_t start = 0;
while (start < text.size() && at > 0)
at -= is_letter_start(text, ++start);
size_t end = std::min(start + 1, text.size());
while (!is_letter_start(text, end))
end++;
return text.substr(start, end - start);
}
void Text::set(Play& play, Entry* next, TextMode mode, const std::string& onEnd)
@@ -169,54 +51,50 @@ void Text::set(Play& play, Entry* next, TextMode mode, const std::string& onEnd)
Interrupt interrupt = mode == TEXT_SEQUENCE ? INTERRUPT_ALWAYS
: mode == TEXT_INTERRUPTIBLE ? INTERRUPT_IF_ALLOWED
: INTERRUPT_NEVER;
if (!next->animation.empty())
play.body_play(next->animation, TRANSITION_NORMAL, interrupt);
delayBase = play.character->dialogue.delay;
delay = delayBase;
play.body.play(next->animation, TRANSITION_NORMAL, interrupt);
delay = delayBase = play.character->dialogue.delay;
for (Effect& effect : next->effects)
{
if (effect.start != 0)
continue;
if (effect.type == EFFECT_DELAY)
delay = delayBase = std::max(1, effect.delay);
if (effect.type == EFFECT_ANIMATION && !effect.animation.empty())
play.body_play(effect.animation, TRANSITION_FORCE);
if (effect.type == EFFECT_ANIMATION)
play.body.play(effect.animation, TRANSITION_FORCE);
}
if (!next->text.empty())
play.isTalking = !is_silent(*next, 0);
}
// Applies an effect as its character comes up: a delay or animation, a screen shake or a sound.
static void effect_apply(Play& play, Text& text, const Effect& effect)
{
if (effect.type == EFFECT_DELAY)
text.delay = text.delayBase = std::max(1, effect.delay);
else if (effect.type == EFFECT_ANIMATION && !effect.animation.empty())
play.body_play(effect.animation, TRANSITION_FORCE);
else if (effect.type == EFFECT_SCREENSHAKE)
play.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME);
else if (effect.type == EFFECT_SOUND)
play.audio->play(effect.sound);
play.body.isTalking = !is_silent(*next, 0);
}
// Applies the effects coming up at a character: delays and animations, screen shakes and sounds.
void Text::effects_apply(Play& play, int at)
{
for (Effect& effect : entry->effects)
if (effect.start == at)
effect_apply(play, *this, effect);
{
if (effect.start != at)
continue;
if (effect.type == EFFECT_DELAY)
delay = delayBase = std::max(1, effect.delay);
else if (effect.type == EFFECT_ANIMATION)
play.body.play(effect.animation, TRANSITION_FORCE);
else if (effect.type == EFFECT_SCREENSHAKE)
play.camera.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME);
else if (effect.type == EFFECT_SOUND)
play.audio->play(effect.sound);
}
}
void Text::clear(Play& play)
{
if (!idleOnEnd.empty())
{
play.idle_override_set(idleOnEnd);
play.idle_play();
play.body.idle_override_set(idleOnEnd);
play.body.idle_play();
idleOnEnd.clear();
}
entry = nullptr;
isScene = false;
play.isTalking = false;
play.body.isTalking = false;
}
bool Text::is_interruptible()
@@ -224,37 +102,65 @@ bool Text::is_interruptible()
return !entry || entry->isTerminal;
}
void Text::update(Play& play, Vec2 screenSize)
// The player's click or key: shows the rest of the line, or else moves to the next entry or closes the text.
void Text::advance(Play& play)
{
if (!entry)
return;
if (!isFinished)
{
for (int skipped = index + 1; skipped <= entry->length; skipped++)
effects_apply(play, skipped);
index = entry->length;
isFinished = true;
play.body.isTalking = false;
isScene &= !entry->isTerminal;
}
else if (!entry->choices.empty() || !entry->next.empty())
{
play.audio->play(play.character->sounds[SOUND_SELECT]);
if (!entry->choices.empty())
choose(play, 0);
else
set(play, play.character->dialogue.entry_get(entry->next), TEXT_SEQUENCE);
}
else
clear(play);
}
void Text::choose(Play& play, int choice)
{
if (entry && choice >= 0 && choice < (int)entry->choices.size())
set(play, play.character->dialogue.entry_get(entry->choices[choice].next), TEXT_SEQUENCE);
}
// Reveals the next character when its delay is up, with its effects, blips and talking. Entries without text wait out
// their animation, and finished last entries close after a while.
void Text::update(Play& play)
{
if (!entry)
return;
Dialogue& dialogue = play.character->dialogue;
if (!isFinished)
{
if (!isStarted)
{
isStarted = true;
for (Effect& effect : entry->effects)
if (effect.start == 0 && (effect.type == EFFECT_SCREENSHAKE || effect.type == EFFECT_SOUND))
effect_apply(play, *this, effect);
if (effect.start == 0 && effect.type == EFFECT_SCREENSHAKE)
play.camera.shake_add(SCREENSHAKE_MAGNITUDE, SCREENSHAKE_TIME);
else if (effect.start == 0 && effect.type == EFFECT_SOUND)
play.audio->play(effect.sound);
}
if (++tick >= delay)
if (!isFinished && ++tick >= delay)
{
tick = 0;
index++;
delay = delayBase;
effects_apply(play, index);
const char* letter = entry->text.c_str();
const char* end = letter + entry->text.size();
unsigned int codepoint = 0;
for (int i = 0; i < index - 1 && letter < end; i++)
letter += ImTextCharFromUtf8(&codepoint, letter, end);
int letterSize = letter < end ? ImTextCharFromUtf8(&codepoint, letter, end) : 0;
auto pause = dialogue.pauses.find(std::string(letter, letterSize));
auto pause = dialogue.pauses.find(letter_get(entry->text, index - 1));
Pause found = pause == dialogue.pauses.end() ? Pause{} : pause->second;
bool isSilentEffect = is_silent(*entry, index - 1);
delay += found.delay;
play.isTalking = !found.isSilent && !isSilentEffect;
play.body.isTalking = !found.isSilent && !isSilentEffect;
bool isBlipStep = dialogue.blipDelay > 0 && index % dialogue.blipDelay == 0;
if (isSilentEffect)
isBlipQueued = false;
@@ -269,112 +175,25 @@ void Text::update(Play& play, Vec2 screenSize)
{
isFinished = true;
isBlipQueued = false;
play.isTalking = false;
play.body.isTalking = false;
isScene &= !entry->isTerminal;
}
}
}
if (entry->text.empty())
{
Anm2Animation* animation = play.actor.animation_get();
bool isReady = entry->animation.empty() || !animation || !play.actor.isPlaying ||
Actor& actor = play.body.actor;
Anm2Animation* animation = actor.animation_get();
bool isReady = entry->animation.empty() || !animation || !actor.isPlaying ||
(!isAnimationStarted && animation->length <= 1) ||
(animation->isLoop && isAnimationStarted && play.actor.time < animationTimePrevious);
(animation->isLoop && isAnimationStarted && actor.time < animationTimePrevious);
isAnimationStarted = true;
animationTimePrevious = play.actor.time;
animationTimePrevious = actor.time;
if (entry->next.empty())
clear(play);
else if (isReady)
set(play, dialogue.entry_get(entry->next), TEXT_SEQUENCE);
return;
}
ImGuiStyle& style = ImGui::GetStyle();
ImVec2 size = {screenSize.x * WIDTH_MULTIPLIER - style.WindowPadding.x * 2,
ImGui::GetTextLineHeightWithSpacing() * HEIGHT_LINES};
ImGui::SetNextWindowSize(size);
ImGui::SetNextWindowPos({screenSize.x / 2 - size.x / 2, screenSize.y - size.y - style.WindowPadding.y});
if (ImGui::Begin("##Text", nullptr,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove))
{
bool isHovered = ImGui::IsWindowHovered();
bool isSpace = ImGui::IsKeyReleased(ImGuiKey_Space);
bool isAdvance = (isHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) || isSpace;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {style.ItemSpacing.x, 0});
if (ImGui::BeginTabBar("##Name"))
{
if (ImGui::BeginTabItem(play.character->name.c_str()))
ImGui::EndTabItem();
ImGui::EndTabBar();
}
ImGui::PushFont(nullptr, FONT_NORMAL);
letters_draw(*entry, index, (float)ImGui::GetTime());
ImVec2 windowPosition = ImGui::GetWindowPos();
ImVec2 contentMin = {windowPosition.x + style.WindowPadding.x, windowPosition.y + style.WindowPadding.y};
ImVec2 contentMax = {windowPosition.x + ImGui::GetWindowWidth() - style.WindowPadding.x,
windowPosition.y + ImGui::GetWindowHeight() - style.WindowPadding.y};
Entry* current = entry;
if (!isFinished && isAdvance)
{
for (int skipped = index + 1; skipped <= entry->length; skipped++)
effects_apply(play, skipped);
index = entry->length;
isFinished = true;
play.isTalking = false;
isScene &= !entry->isTerminal;
}
else if (isFinished && !current->choices.empty())
{
float buttonY = contentMax.y - ImGui::GetFrameHeight();
float separatorY = buttonY - style.ItemSpacing.y;
ImGui::GetWindowDrawList()->AddLine({contentMin.x, separatorY}, {contentMax.x, separatorY},
ImGui::GetColorU32(ImGuiCol_Separator));
ImGui::SetCursorScreenPos({contentMin.x, buttonY});
ImVec2 buttonSize = {row_width_get((int)current->choices.size()), 0};
for (int i = 0; i < (int)current->choices.size(); i++)
{
Choice& choice = current->choices[i];
ImGui::PushID(i);
if (is_fx(ImGui::Button(choice.text.c_str(), buttonSize)))
set(play, dialogue.entry_get(choice.next), TEXT_SEQUENCE);
ImGui::SetItemTooltip("%s", choice.text.c_str());
ImGui::PopID();
if (i + 1 < (int)current->choices.size())
ImGui::SameLine();
}
if (isHovered && isSpace)
{
play.audio->play(play.character->sounds[SOUND_SELECT]);
set(play, dialogue.entry_get(current->choices.front().next), TEXT_SEQUENCE);
}
}
else if (isFinished && !current->next.empty())
{
float side = ImGui::GetTextLineHeightWithSpacing();
ImVec2 center = {contentMax.x - side / 2 +
std::sin((float)ImGui::GetTime() * INDICATOR_SPEED) * side * INDICATOR_BOB,
contentMax.y - side / 2};
float half = side * INDICATOR_SIZE;
ImGui::GetWindowDrawList()->AddTriangleFilled({center.x + half, center.y},
{center.x - half, center.y - half},
{center.x - half, center.y + half},
ImGui::GetColorU32(ImGuiCol_Text));
if (isAdvance)
{
play.audio->play(play.character->sounds[SOUND_SELECT]);
set(play, dialogue.entry_get(current->next), TEXT_SEQUENCE);
}
}
else if (isFinished && isAdvance)
clear(play);
ImGui::PopFont();
ImGui::PopStyleVar();
}
ImGui::End();
if (entry && isFinished && entry->isTerminal && (life += ImGui::GetIO().DeltaTime) > LIFE_MAX)
else if (isFinished && entry->isTerminal && (life += TICK) > LIFE_MAX)
clear(play);
}
+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.
// A scene holds the game still until its last entry finishes.
// A scene holds the game still until its last entry finishes. The UI shows it and passes on the player's input.
class Text
{
public:
@@ -31,7 +31,9 @@ public:
bool isStarted = false;
void set(Play& play, Entry* next, TextMode mode = TEXT_INTERRUPTIBLE, const std::string& onEnd = "");
void update(Play& play, Vec2 screenSize);
void update(Play& play);
void advance(Play& play);
void choose(Play& play, int choice);
void clear(Play& play);
bool is_interruptible();
+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 <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 ROTATION_GROUND_DAMPING = 0.85f;
constexpr float ROTATION_MAX = 90;
constexpr float ROTATION_RETURN_SPEED = 120;
constexpr float THROW_THRESHOLD = 10;
void Play::item_add(int id, Vec2 position, Vec2 velocity, float rotation)
void Items::add(Play& play, int id, Vec2 position, Vec2 velocity, float rotation)
{
ItemEntity& entity = items.emplace_back(ItemEntity{character->item_actor_get(id), velocity, 0, id});
ItemEntity& entity = list.emplace_back(ItemEntity{play.character->item_actor_get(id), velocity, 0, id});
entity.actor.position = position;
entity.actor.overrides.push_back({.rotation = rotation, .target = TARGET_ROOT, .isRotation = true});
entity.actor.rotation = rotation;
discovered[id] = true;
}
int Play::spawn_delay_get()
// Forgets which item is being eaten, and lets go of the held one unless asked to keep it.
void Items::drop(Hold hold)
{
return std::max(1, (int)std::round(character->spawnDelay * std::pow(character->spawnDelayMultiplier, stage)));
if (hold == HOLD_RELEASE)
{
held = -1;
feeding.animation = -1;
}
feeding.item = -1;
feeding.event = -1;
feeding.rect = {NAN, NAN, NAN, NAN};
}
int Items::spawn_delay_get(Play& play)
{
Catalog& catalog = play.character->catalog;
return std::max(1, (int)std::round(catalog.spawnDelay * std::pow(catalog.spawnDelayMultiplier, play.body.stage)));
}
static float spawn_weight_get(Play& play, int id)
{
Item& item = play.character->items[id];
if (item.calories > play.capacity_max_get() || item.rarity < 0)
Catalog& catalog = play.character->catalog;
Item& item = catalog.items[id];
if (item.calories > play.body.capacity_max_get() || item.rarity < 0)
return 0;
return std::max(0.0f, play.character->rarities[item.rarity].weight);
return std::max(0.0f, catalog.rarities[item.rarity].weight);
}
bool Play::is_spawn_possible()
bool Items::is_spawn_possible(Play& play)
{
if ((int)items.size() >= character->spawnLimit)
Catalog& catalog = play.character->catalog;
if ((int)list.size() >= catalog.spawnLimit)
return false;
for (int i = 0; i < (int)character->items.size(); i++)
if (spawn_weight_get(*this, i) > 0)
for (int i = 0; i < (int)catalog.items.size(); i++)
if (spawn_weight_get(play, i) > 0)
return true;
return false;
}
void Play::spawn_queue_add(int id)
void Items::spawn_queue_add(Play& play, int id)
{
if (id < 0 || id >= (int)character->items.size())
Catalog& catalog = play.character->catalog;
if (id < 0 || id >= (int)catalog.items.size())
return;
Item& item = character->items[id];
audio->play(character->sounds[SOUND_SUMMON]);
if (item.rarity >= 0)
audio->play(character->rarities[item.rarity].sound);
play.audio->play(play.character->sounds[SOUND_SUMMON]);
if (catalog.items[id].rarity >= 0)
play.audio->play(catalog.rarities[catalog.items[id].rarity].sound);
spawnQueue.push_back(id);
spawnDelay = spawn_delay_get();
spawnDelay = spawn_delay_get(play);
}
// Queues an item picked at random by rarity, once the spawn delay is up.
void Play::spawn_queue_add()
void Items::spawn_queue_add(Play& play)
{
if (spawnDelay > 0 || !is_spawn_possible())
if (spawnDelay > 0 || !is_spawn_possible(play))
return;
std::vector<Item>& items = play.character->catalog.items;
float total = 0;
for (int i = 0; i < (int)character->items.size(); i++)
total += spawn_weight_get(*this, i);
for (int i = 0; i < (int)items.size(); i++)
total += spawn_weight_get(play, i);
float roll = random_get(0, total);
for (int i = 0; i < (int)character->items.size(); i++)
if (float weight = spawn_weight_get(*this, i); weight > 0 && (roll -= weight) < 0)
return spawn_queue_add(i);
for (int i = 0; i < (int)items.size(); i++)
if (float weight = spawn_weight_get(play, i); weight > 0 && (roll -= weight) < 0)
return spawn_queue_add(play, i);
}
// Where an eat area takes food: its null now, or at the start of its animation when it isn't showing.
Rect Play::eat_rect_get(const EatArea& eatArea)
void Items::update(Play& play, Pointer pointer)
{
Rect rect = actor.null_rect_get(eatArea.null);
if (!is_rect_valid(rect))
rect = actor.null_rect_get(eatArea.null, actor.anm2->animation_get(eatArea.animation + suffix_get()), 0);
return rect;
}
// Forgets which item is being eaten, and lets go of the held one when asked.
void Play::item_drop(Hold hold)
{
heldItem = hold == HOLD_RELEASE ? -1 : heldItem;
eatAnimation = hold == HOLD_RELEASE ? -1 : eatAnimation;
eatItem = -1;
eatEvent = -1;
eatRect = {NAN, NAN, NAN, NAN};
}
static Actor& pointer_actor_get(Play& play)
{
return play.isAutofeedActive ? play.autoCursor : play.cursor;
}
void Play::items_update(Pointer pointer)
{
Character& data = *character;
Actor& active = pointer_actor_get(*this);
int areaIndex = data.area_get(stage);
Area area = areaIndex >= 0 ? data.areas[areaIndex] : Area{};
bool isOver = is_over_capacity();
auto text_play = [&](const char* hook) {
Character& character = *play.character;
Body& body = play.body;
Stats& stats = body.stats;
Actor& active = play.pointer_actor_get();
Audio& audio = *play.audio;
int areaIndex = character.area_get(body.stage);
Area area = areaIndex >= 0 ? character.areas[areaIndex] : Area{};
Rect bounds = play.bounds;
bool isOver = body.is_over_capacity();
auto text_play = [&](Hook hook) {
if (!pointer.isAutomated)
text.set(*this, data.dialogue.hook_get(hook));
play.text.set(play, character.dialogue.hook_get(hook));
};
auto held_release = [&](bool isThrown) {
item_drop();
active.play(data.cursorIdle);
if (!isThrown && !actor.pending)
idle_play();
auto release = [&](bool isThrown) {
drop();
active.play(character.cursor.idle);
if (!isThrown && !body.actor.pending)
body.idle_play();
};
if (text.isScene && heldItem >= 0)
if (play.text.isScene && held >= 0)
{
held_release(false);
isEatTextPending = false;
release(false);
feeding.isTextPending = false;
}
if (isEatTextPending && eatAnimation == actor.animation && (!actor.isPlaying || actor.is_finished()))
if (feeding.isTextPending && feeding.animation == body.actor.animation &&
(!body.actor.isPlaying || body.actor.is_finished()))
{
text.set(*this, data.dialogue.hook_get(isEatTextFull ? "EatFull" : "Eat"));
isEatTextPending = false;
eatAnimation = -1;
play.text.set(play, character.dialogue.hook_get(feeding.isTextFull ? HOOK_EAT_FULL : HOOK_EAT));
feeding.isTextPending = false;
feeding.animation = -1;
}
bool wasItemHovered = isItemHovered;
isItemHovered = false;
tooltipItem = -1;
bool wasHovered = isHovered;
isHovered = false;
tooltip = -1;
if (spawnDelay > 0)
spawnDelay--;
for (int id : spawnQueue)
@@ -142,149 +126,151 @@ void Play::items_update(Pointer pointer)
Vec2 position = {spawn.x + random_get(0, spawn.w), spawn.y + random_get(0, spawn.h)};
if (!discovered[id])
{
toast_add(STRING_ToastNewItemFound);
audio->play(data.sounds[SOUND_NEW_ITEM]);
play.toast_add("New item found!");
audio.play(character.sounds[SOUND_NEW_ITEM]);
}
item_add(id, position);
progress.totalSpawned++;
particles_spawn(data.summonParticles, position);
add(play, id, position);
stats.totalSpawned++;
play.particles_spawn(character.catalog.summonParticles, position);
}
spawnQueue.clear();
if (heldItem >= 0 && heldItem < (int)items.size())
if (held >= 0 && held < (int)list.size())
{
ItemEntity& held = items[heldItem];
Item& item = data.items[held.id];
float& rotation = held.rotation_get();
Vec2 delta = itemPointerPrevious - pointer.position;
held.actor.position = pointer.position;
held.velocity = {};
held.angularVelocity = (held.angularVelocity - delta.x * HOLD_SPIN) * area.friction;
ItemEntity& entity = list[held];
Item& item = character.catalog.items[entity.id];
float& rotation = *entity.actor.rotation;
Vec2 delta = pointerPrevious - pointer.position;
entity.actor.position = pointer.position;
entity.velocity = {};
entity.angularVelocity = (entity.angularVelocity - delta.x * HOLD_SPIN) * area.friction;
rotation = std::clamp(rotation * area.friction, -ROTATION_MAX, ROTATION_MAX);
int removed = -1;
if (item.use == USE_EDIBLE)
{
bool isEdible = item.calories > 0 && progress.calories + item.calories <= capacity_max_get();
if (isJustItemHeld)
text_play(isEdible ? (isOver ? "FeedFull" : "Feed")
: item.calories > progress.capacity ? "LowCapacity"
: "Full");
isJustItemHeld = false;
for (EatArea& eatArea : data.eatAreas)
bool isEdible = item.calories > 0 && stats.calories + item.calories <= body.capacity_max_get();
if (isJustHeld)
text_play(isEdible ? (isOver ? HOOK_FEED_FULL : HOOK_FEED)
: item.calories > stats.capacity ? HOOK_LOW_CAPACITY
: HOOK_FULL);
isJustHeld = false;
for (EatArea& eatArea : character.eatAreas)
{
Rect rect = eat_rect_get(eatArea);
bool isInside = is_point_in_rect(rect, held.actor.position);
Rect rect = body.eat_rect_get(eatArea);
bool isInside = is_point_in_rect(rect, entity.actor.position);
bool isAreaActive = isEdible && isInside;
bool isPending = eatItem == heldItem && eatEvent == eatArea.event;
bool isPendingActive = isPending && is_point_in_rect(eatRect, held.actor.position);
std::string eatName = eatArea.animation + suffix_get();
bool isPending = feeding.item == held && feeding.event == eatArea.event;
bool isPendingActive = isPending && is_point_in_rect(feeding.rect, entity.actor.position);
std::string eatName = eatArea.animation + body.suffix;
if (isAreaActive || isPendingActive)
{
if (isPending && eatArea.event >= 0 && actor.event == eatArea.event)
if (isPending && eatArea.event >= 0 && body.actor.event == eatArea.event)
{
actor.event = -1;
progress.calories += item.calories;
progress.totalCalories += item.calories;
progress.capacity = std::clamp(progress.capacity + item.capacityBonus, data.capacityMin,
data.capacityMax);
progress.digestionRate = std::clamp(progress.digestionRate + item.digestionBonus,
data.digestionRateMin, data.digestionRateMax);
progress.totalConsumed++;
removed = heldItem;
item_drop();
eatAnimation = actor.animation;
isEatTextPending = !pointer.isAutomated;
isEatTextFull = isOver;
Traits& traits = character.traits;
body.actor.event = -1;
stats.calories += item.calories;
stats.totalCalories += item.calories;
stats.capacity =
std::clamp(stats.capacity + item.capacityBonus, traits.capacityMin, traits.capacityMax);
stats.digestionRate = std::clamp(stats.digestionRate + item.digestionBonus,
traits.digestionRateMin, traits.digestionRateMax);
stats.totalConsumed++;
removed = held;
drop();
feeding.animation = body.actor.animation;
feeding.isTextPending = !pointer.isAutomated;
feeding.isTextFull = isOver;
break;
}
eatItem = heldItem;
eatEvent = eatArea.event;
feeding.item = held;
feeding.event = eatArea.event;
if (isAreaActive)
eatRect = rect;
if (!actor.is_playing(eatName))
body_play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS);
feeding.rect = rect;
if (!body.actor.is_playing(eatName))
body.play(eatArea.animation, TRANSITION_NORMAL, INTERRUPT_ALWAYS);
}
else if (eatItem == heldItem)
item_drop(HOLD_KEEP);
if (actor.is_playing(eatName) && !isInside)
text_play(isOver ? "FoodTakenFull" : "FoodTaken");
else if (feeding.item == held)
drop(HOLD_KEEP);
if (body.actor.is_playing(eatName) && !isInside)
text_play(isOver ? HOOK_FOOD_TAKEN_FULL : HOOK_FOOD_TAKEN);
if (isAreaActive)
break;
}
}
else if (item.use == USE_TOOL && item.isToggleSpritesheet &&
is_point_in_rect(actor.bounds_get(), held.actor.position) && data.alternateTexture &&
data.alternateSheet < (int)actor.sheets.size())
is_point_in_rect(body.actor.bounds_get(), entity.actor.position) && character.alternate.texture &&
character.alternate.sheet < (int)body.actor.sheets.size())
{
progress.isAlternate = !progress.isAlternate;
actor.sheets[data.alternateSheet] =
progress.isAlternate ? data.alternateTexture : data.anm2->sheets[data.alternateSheet];
audio->play(data.alternateSound);
removed = heldItem;
item_drop();
stats.isAlternate = !stats.isAlternate;
body.actor.sheets[character.alternate.sheet] =
stats.isAlternate ? character.alternate.texture : character.anm2->sheets[character.alternate.sheet];
audio.play(character.alternate.sound);
removed = held;
drop();
}
if (removed < 0 && pointer.isReleased)
{
bool isThrown = std::abs(delta.x) >= THROW_THRESHOLD || std::abs(delta.y) >= THROW_THRESHOLD;
audio->play(data.sounds[isThrown ? SOUND_THROW : SOUND_RELEASE]);
audio.play(character.sounds[isThrown ? SOUND_THROW : SOUND_RELEASE]);
if (isThrown)
text_play("Throw");
held.velocity = held.velocity - delta;
held_release(isThrown);
text_play(HOOK_THROW);
entity.velocity = entity.velocity - delta;
release(isThrown);
}
if (removed >= 0)
items.erase(items.begin() + removed);
list.erase(list.begin() + removed);
}
int moved = -1;
bool isTargetLocked = pointer.target >= 0 && pointer.target < (int)items.size();
bool isTargetLocked = pointer.target >= 0 && pointer.target < (int)list.size();
float right = bounds.x + bounds.w;
float bottom = bounds.y + bounds.h;
for (int i = 0; i < (int)items.size(); i++)
for (int i = 0; i < (int)list.size(); i++)
{
ItemEntity& entity = items[i];
ItemEntity& entity = list[i];
Vec2& position = entity.actor.position;
Vec2& velocity = entity.velocity;
float& rotation = entity.rotation_get();
float gravity = data.items[entity.id].gravity != 0 ? data.items[entity.id].gravity : area.gravity;
entity.actor.update(*audio);
float& rotation = *entity.actor.rotation;
float gravity = character.catalog.items[entity.id].gravity;
gravity = gravity != 0 ? gravity : area.gravity;
entity.actor.update(audio);
position = position + velocity;
rotation += entity.angularVelocity;
if (is_point_in_rect(entity.actor.bounds_get(), pointer.position) && !pointer.isCaptured &&
(!isTargetLocked || i == pointer.target))
{
isItemHovered = true;
tooltipItem = heldItem == i || pointer.isAutomated ? -1 : entity.id;
active.play(data.cursorHover, TRANSITION_IF_IDLE);
cursorState = CURSOR_HOVER;
isHovered = true;
tooltip = held == i || pointer.isAutomated ? -1 : entity.id;
active.play(character.cursor.hover, TRANSITION_IF_IDLE);
play.cursorState = CURSOR_HOVER;
if (pointer.isClicked)
{
audio->play(data.sounds[SOUND_GRAB]);
particles_spawn(data.grabParticles, pointer.position);
isJustItemHeld = true;
audio.play(character.sounds[SOUND_GRAB]);
play.particles_spawn(character.cursor.grabParticles, pointer.position);
isJustHeld = true;
}
if (pointer.isDown)
{
active.play(data.cursorGrab);
cursorState = CURSOR_ACTION;
heldItem = moved = i;
active.play(character.cursor.grab);
play.cursorState = CURSOR_ACTION;
held = moved = i;
}
if (pointer.isRightClicked)
{
audio->play(data.sounds[SOUND_DISPOSE]);
active.play(data.cursorDispose, TRANSITION_FORCE, INTERRUPT_NEVER);
particles_spawn(data.disposeParticles, position);
heldItem = heldItem == i ? -1 : heldItem > i ? heldItem - 1 : heldItem;
audio.play(character.sounds[SOUND_DISPOSE]);
active.play(character.cursor.dispose, TRANSITION_FORCE, INTERRUPT_NEVER);
play.particles_spawn(character.catalog.disposeParticles, position);
held = held == i ? -1 : held > i ? held - 1 : held;
moved = moved == i ? -1 : moved > i ? moved - 1 : moved;
items.erase(items.begin() + i--);
list.erase(list.begin() + i--);
continue;
}
}
if (i != heldItem)
if (i != held)
{
if (position.x <= bounds.x || position.x >= right)
{
@@ -292,7 +278,7 @@ void Play::items_update(Pointer pointer)
velocity.x = -velocity.x * area.friction;
entity.angularVelocity = -entity.angularVelocity * area.friction;
rotation = -rotation;
audio->play(data.sounds[SOUND_BOUNCE]);
audio.play(character.sounds[SOUND_BOUNCE]);
}
if (position.y <= bounds.y || position.y >= bottom)
{
@@ -301,7 +287,7 @@ void Play::items_update(Pointer pointer)
position.y = bottom;
velocity = {velocity.x * area.friction, 0};
rotation = std::remainder(rotation, 360.0f);
float step = ROTATION_RETURN_SPEED * ImGui::GetIO().DeltaTime;
float step = ROTATION_RETURN_SPEED * TICK;
rotation = std::abs(rotation) <= step ? 0 : rotation - std::copysign(step, rotation);
entity.angularVelocity *= ROTATION_GROUND_DAMPING;
}
@@ -309,7 +295,7 @@ void Play::items_update(Pointer pointer)
{
velocity.y = -velocity.y;
entity.angularVelocity *= area.friction;
audio->play(data.sounds[SOUND_BOUNCE]);
audio.play(character.sounds[SOUND_BOUNCE]);
}
velocity.y *= area.friction;
}
@@ -319,276 +305,14 @@ void Play::items_update(Pointer pointer)
position = {std::clamp(position.x, bounds.x, right), std::clamp(position.y, bounds.y, bottom)};
}
if (moved >= 0 && moved < (int)items.size() - 1)
if (moved >= 0 && moved < (int)list.size() - 1)
{
ItemEntity entity = std::move(items[moved]);
items.erase(items.begin() + moved);
items.push_back(std::move(entity));
heldItem = (int)items.size() - 1;
ItemEntity entity = std::move(list[moved]);
list.erase(list.begin() + moved);
list.push_back(std::move(entity));
held = (int)list.size() - 1;
}
if (wasItemHovered && !isItemHovered)
active.play(data.cursorIdle);
itemPointerPrevious = pointer.position;
}
void Play::interact_update(Pointer pointer)
{
Character& data = *character;
Actor& active = pointer_actor_get(*this);
if (text.isScene)
{
isInteracting = isHoldInteracting = isHovering = false;
interactArea = -1;
return;
}
bool wasInteracting = isInteracting;
bool wasHolding = isHoldInteracting;
bool wasHovering = isHovering;
isHovering = false;
isHoldInteracting &= isInteracting;
dialogueCooldowns.resize(data.interactAreas.size());
for (int& cooldown : dialogueCooldowns)
cooldown = text.entry ? std::max(0, cooldown - 1) : 0;
for (int i = 0; i < (int)data.interactAreas.size(); i++)
{
InteractArea& area = data.interactAreas[i];
Rect rect = actor.null_rect_get(area.null);
bool isInside = is_point_in_rect(rect, pointer.position);
if (cursorState == CURSOR_DEFAULT && isInside && !pointer.isCaptured && area.type == pointer.type)
{
cursorState = CURSOR_HOVER;
active.play(area.cursorHover, TRANSITION_IF_IDLE);
isHovering = true;
interactArea = i;
bool isHoldStart = area.isHold && (pointer.isClicked || (pointer.isDown && !isInteracting));
if (isHoldStart || (!area.isHold && pointer.isClicked))
{
if (area.isHold)
isInteracting = isHoldInteracting = true;
else
{
cursorState = CURSOR_ACTION;
active.play(area.cursorActive, TRANSITION_FORCE, INTERRUPT_ALWAYS);
}
audio->play(area.sounds);
idle_override_set(area.idleOnEnd);
particles_spawn(area.particles, pointer.position);
if (area.digestionClick > 0 && progress.calories > 0 && !progress.isDigesting)
progress.digestionProgress += area.digestionClick;
if (area.layer >= 0)
actor.overrides.push_back({.amplitude = {area.scaleAmplitude, area.scaleAmplitude},
.cycles = area.scaleCycles,
.time = area.scaleTime,
.timeStart = area.scaleTime,
.id = area.layer});
bool isReady = dialogueCooldowns[i] <= 0;
dialogueCooldowns[i] = std::max(0, area.dialogueCooldown);
bool isFull = is_over_capacity() && data.dialogue.pools.contains(area.dialoguePoolFull);
const std::string& pool = isFull ? area.dialoguePoolFull : area.dialoguePool;
if (data.dialogue.pools.contains(pool))
{
if (text.is_interruptible() && isReady)
text.set(*this, data.dialogue.pool_get(pool), TEXT_INTERRUPTIBLE,
area.isHold ? "" : area.idleOnEnd);
}
else if (area.dialoguePool.empty() && !area.animation.empty())
body_play(area.animation, TRANSITION_FORCE);
}
if (isInteracting)
{
cursorState = CURSOR_ACTION;
active.play(area.cursorActive);
Vec2 moved = pointer.position - pointerPrevious;
if (area.digestionHover > 0 && progress.calories > 0 && !progress.isDigesting &&
(moved.x != 0 || moved.y != 0))
progress.digestionProgress += area.digestionHover;
}
}
if ((i == interactArea && !isInside) || pointer.isReleased || pointer.isCaptured)
{
isInteracting = isHoldInteracting = false;
interactArea = -1;
}
}
if ((wasInteracting && !isInteracting) || (wasHovering && !isHovering))
active.play(data.cursorIdle);
if (wasHolding && !isHoldInteracting && !actor.pending)
idle_play();
if (wasHovered && !isHovered)
active.play(character.cursor.idle);
pointerPrevious = pointer.position;
if (isJustDigested && text.is_interruptible())
text.set(*this, data.dialogue.hook_get("Digest"));
if (isJustStageUp)
text.set(*this, data.dialogue.hook_get("StageUp"));
}
// Moves the cursor toward a target, braking to land on it. Returns whether it's there.
static bool is_target_reached(Vec2& position, Vec2& velocity, Vec2 target, float speed, float acceleration)
{
Vec2 delta = target - position;
float distance = vec2_length(delta);
if (!std::isfinite(distance))
return false;
if (distance <= AUTO_TARGET_EPSILON)
{
position = target;
velocity = {};
return true;
}
Vec2 desired = delta / distance * std::min(speed, std::sqrt(AUTO_BRAKING * acceleration * distance));
Vec2 change = desired - velocity;
float changeLength = vec2_length(change);
velocity = changeLength > acceleration ? velocity + change / changeLength * acceleration : desired;
if (vec2_length(velocity) >= distance)
{
position = target;
velocity = {};
return true;
}
position = position + velocity;
return false;
}
static bool is_autofeed_food(const Item& item)
{
return item.use == USE_EDIBLE && item.capacityBonus >= 0 && item.digestionBonus >= 0;
}
static bool is_edible(Play& play, const Item& item)
{
return is_autofeed_food(item) && item.calories > 0 && play.progress.calories + item.calories <= play.capacity_max_get();
}
// The edible item in the world with the most calories, ties broken at random.
static int food_best_get(Play& play)
{
int best = -1;
int ties = 0;
for (int i = 0; i < (int)play.items.size(); i++)
{
Item& item = play.character->items[play.items[i].id];
if (!is_edible(play, item))
continue;
float bestCalories = best >= 0 ? play.character->items[play.items[best].id].calories : -1;
if (item.calories > bestCalories)
best = i, ties = 1;
else if (item.calories == bestCalories && SDL_rand(++ties) == 0)
best = i;
}
return best;
}
// Plays for the player: spawns food, carries it to the mouth, and rubs or pats the belly along when nothing fits.
void Play::autofeed_update(Pointer& itemPointer, Pointer& interactPointer)
{
Character& data = *character;
isAutofeedActive = false;
autoCursor.isVisible = false;
auto reset = [&]() {
isAutoItemHeld = isAutoDigestHeld = false;
autoTarget = -1;
autoClickCooldown = 0;
autoVelocity = {};
};
bool isWorldFood = false;
bool isAnyEdible = false;
for (ItemEntity& entity : items)
{
isWorldFood |= is_autofeed_food(data.items[entity.id]);
isAnyEdible |= is_edible(*this, data.items[entity.id]);
}
if (!isAutofeed || isStageUp || isJustStageUp || isJustStageFinal)
return reset();
spawn_queue_add();
if (progress.calories <= AUTO_CALORIE_EPSILON && !isWorldFood)
return reset();
if (autoCursor.position.x == 0 && autoCursor.position.y == 0)
autoCursor.position = actor.position;
Rect bodyBounds = actor.bounds_get();
float speedScale = is_rect_valid(bodyBounds) ? std::clamp(std::sqrt(bodyBounds.w * bodyBounds.h / AUTO_SCALE_AREA),
AUTO_SCALE_MIN, AUTO_SCALE_MAX)
: 1;
Rect mouth = {NAN, NAN, NAN, NAN};
for (EatArea& eatArea : data.eatAreas)
if (!is_rect_valid(mouth))
mouth = eat_rect_get(eatArea);
bool isMouth = is_rect_valid(mouth);
auto move = [&](Vec2 target, float speed, float acceleration) {
isAutofeedActive = autoCursor.isVisible = true;
itemPointer = interactPointer = {.position = autoCursor.position, .isAutomated = true};
bool isThere = is_target_reached(autoCursor.position, autoVelocity, target, speed * speedScale,
acceleration * speedScale);
itemPointer.position = interactPointer.position = autoCursor.position;
return isThere;
};
if (heldItem >= 0 && heldItem < (int)items.size())
{
if (isMouth && is_edible(*this, data.items[items[heldItem].id]))
{
move(rect_middle(mouth), AUTO_SPEED, AUTO_ACCELERATION);
itemPointer.isDown = isAutoItemHeld = true;
isAutoDigestHeld = false;
return;
}
move(autoCursor.position, AUTO_SPEED, AUTO_ACCELERATION);
itemPointer.isReleased = true;
isAutoItemHeld = false;
autoTarget = -1;
return;
}
bool isTargetValid = autoTarget >= 0 && autoTarget < (int)items.size() &&
is_edible(*this, data.items[items[autoTarget].id]);
if (!isMouth || !isTargetValid)
autoTarget = isMouth ? food_best_get(*this) : -1;
if (autoTarget >= 0)
{
Rect rect = items[autoTarget].actor.bounds_get();
if (!is_rect_valid(rect))
return reset();
move(rect_middle(rect), AUTO_SPEED, AUTO_ACCELERATION);
bool isInside = is_point_in_rect(rect, autoCursor.position);
itemPointer.target = autoTarget;
itemPointer.isClicked = isInside && !isAutoItemHeld;
itemPointer.isDown = isInside || isAutoItemHeld;
isAutoItemHeld |= isInside;
isAutoDigestHeld = false;
return;
}
if (!(isWorldFood || !isAnyEdible))
return reset();
InteractArea* digestArea = nullptr;
for (InteractArea& area : data.interactAreas)
if (!digestArea && (area.digestionClick > 0 || area.digestionHover > 0))
digestArea = &area;
Rect rect = digestArea ? actor.null_rect_get(digestArea->null) : Rect{NAN, NAN, NAN, NAN};
if (!is_rect_valid(rect))
return reset();
autoAngle += AUTO_ANGLE_STEP;
float radius = std::max(1.0f, std::min(rect.w, rect.h) * AUTO_DIGEST_RADIUS);
Vec2 target = rect_middle(rect) + Vec2{std::cos(autoAngle), std::sin(autoAngle)} * radius;
bool isThere = move(target, AUTO_DIGEST_SPEED, AUTO_DIGEST_ACCELERATION);
interactPointer.type = digestArea->type;
if (digestArea->digestionHover > 0)
{
bool isInside = is_point_in_rect(rect, autoCursor.position);
interactPointer.isClicked = isInside && !isAutoDigestHeld;
interactPointer.isDown = isInside;
interactPointer.isReleased = isAutoDigestHeld && !isInside;
isAutoDigestHeld = isInside;
}
else
{
autoClickCooldown = std::max(0, autoClickCooldown - 1);
interactPointer.isClicked = isThere && autoClickCooldown <= 0 && !isAutoDigestHeld;
interactPointer.isReleased = isAutoDigestHeld;
if (interactPointer.isClicked)
autoClickCooldown = AUTO_CLICK_COOLDOWN;
isAutoDigestHeld = interactPointer.isClicked;
}
isAutoItemHeld = false;
}
+103 -98
View File
@@ -3,7 +3,6 @@
#include "ui.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <utility>
#include <imgui_impl_opengl3.h>
#include <imgui_impl_sdl3.h>
@@ -29,11 +28,13 @@ enum Screen
SCREEN_PLAY
};
// Everything the game holds, from start to exit.
// Everything the game holds, from start to exit. Long-lived parts keep pointers to the shared ones (the audio,
// renderer and command list) set here once.
struct App
{
std::vector<Character> characters;
Audio audio;
Commands commands;
Console console;
Play play;
Renderer renderer;
@@ -44,55 +45,54 @@ struct App
SDL_Window* window = nullptr;
Uint64 tickPrevious = 0;
Screen screen = SCREEN_SELECT;
bool isCursorHidden = false;
bool isRunning = true;
};
static App game;
static App app;
static int character_get(const std::string& key)
{
for (int i = 0; i < (int)game.characters.size(); i++)
if (game.characters[i].key == key)
for (int i = 0; i < (int)app.characters.size(); i++)
if (app.characters[i].key == key)
return i;
return -1;
}
static void play_exit()
{
if (game.screen != SCREEN_PLAY)
if (app.screen != SCREEN_PLAY)
return;
game.play.save();
game.settings.lastCharacter.clear();
ImGui::GetIO().FontDefault = game.font;
style_set(game.settings.color);
widget_sounds_set(nullptr, nullptr, nullptr);
game.screen = SCREEN_SELECT;
app.play.save();
app.settings.lastCharacter.clear();
ImGui::GetIO().FontDefault = app.font;
style_set(app.settings.color);
app.screen = SCREEN_SELECT;
}
static void play_start(int index, Game mode)
static void play_start(const std::string& key, Game game)
{
int index = character_get(key);
if (index < 0)
return;
play_exit();
Character& character = game.characters[index];
game.ui.characterIndex = index;
game.play.set(character, mode, game.renderer, game.audio);
game.settings.lastCharacter = character.key;
ImGui::GetIO().FontDefault = character.font ? character.font : game.font;
style_set(game.settings.isCharacterColor ? character.color : game.settings.color, character.rounding);
widget_sounds_set(&game.audio, &character.sounds[SOUND_HOVER], &character.sounds[SOUND_SELECT]);
game.ui.itemActors.clear();
game.ui.selectedItem = -1;
game.screen = SCREEN_PLAY;
Character& character = app.characters[index];
app.ui.characterIndex = index;
app.play.set(character, game, app.renderer, app.audio);
app.settings.lastCharacter = character.key;
ImGui::GetIO().FontDefault = character.menu.font ? character.menu.font : app.font;
style_set(app.settings.isCharacterColor ? character.color : app.settings.color, character.menu.rounding);
app.screen = SCREEN_PLAY;
}
static void characters_reload()
{
std::string key = game.screen == SCREEN_PLAY ? game.play.character->key : "";
std::string key = app.screen == SCREEN_PLAY ? app.play.character->key : "";
play_exit();
game.ui.previewActor = {};
game.characters = characters_load(game.renderer);
play_start(character_get(key), GAME_CONTINUE);
app.ui.previewActor = {};
app.ui.catalogCharacter = nullptr;
app.characters = characters_load(app.renderer);
play_start(key, GAME_CONTINUE);
}
static void init()
@@ -100,29 +100,29 @@ static void init()
if (!SDL_Init(SDL_INIT_VIDEO))
{
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
game.isRunning = false;
app.isRunning = false;
return;
}
game.settings.load();
app.settings.load();
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, GL_MAJOR);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, GL_MINOR);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, GL_PROFILE);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
game.window = SDL_CreateWindow(GAME_NAME, game.settings.width, game.settings.height,
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);
SDL_SetWindowPosition(game.window, game.settings.x, game.settings.y);
game.context = game.window ? SDL_GL_CreateContext(game.window) : nullptr;
if (game.context)
game.renderer.init();
if (!game.renderer.isValid)
app.window =
SDL_CreateWindow(GAME_NAME, app.settings.width, app.settings.height, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);
SDL_SetWindowPosition(app.window, app.settings.x, app.settings.y);
app.context = app.window ? SDL_GL_CreateContext(app.window) : nullptr;
if (app.context)
app.renderer.init();
if (!app.renderer.isValid)
{
SDL_Log("Failed to initialize OpenGL: %s", SDL_GetError());
game.isRunning = false;
app.isRunning = false;
return;
}
SDL_GL_SetSwapInterval(1);
game.audio.init();
game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT);
app.audio.init();
app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT);
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
@@ -131,14 +131,19 @@ static void init()
io.KeyRepeatDelay = KEY_REPEAT_DELAY;
io.KeyRepeatRate = KEY_REPEAT_RATE;
ImGui::StyleColorsDark();
ImGui_ImplSDL3_InitForOpenGL(game.window, game.context);
ImGui_ImplSDL3_InitForOpenGL(app.window, app.context);
ImGui_ImplOpenGL3_Init(GLSL_VERSION);
game.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL);
io.FontDefault = game.font;
style_set(game.settings.color);
app.font = io.Fonts->AddFontFromFileTTF(path_get("font/font.ttf").c_str(), FONT_NORMAL);
io.FontDefault = app.font;
style_set(app.settings.color);
game.characters = characters_load(game.renderer);
play_start(character_get(game.settings.lastCharacter), GAME_CONTINUE);
app.ui.audio = &app.audio;
app.ui.renderer = &app.renderer;
app.ui.commands = &app.commands;
app.console.renderer = &app.renderer;
app.console.commands = &app.commands;
app.characters = characters_load(app.renderer);
play_start(app.settings.lastCharacter, GAME_CONTINUE);
}
static void events_handle()
@@ -148,94 +153,94 @@ static void events_handle()
{
ImGui_ImplSDL3_ProcessEvent(&event);
if (event.type == SDL_EVENT_QUIT)
game.isRunning = false;
app.isRunning = false;
if (event.type != SDL_EVENT_KEY_DOWN || event.key.repeat)
continue;
bool isTyping = ImGui::GetIO().WantTextInput;
bool isAltEnter = event.key.key == SDLK_RETURN && (event.key.mod & SDL_KMOD_ALT);
if (event.key.key == SDLK_F11 || isAltEnter || (!isTyping && event.key.scancode == SDL_SCANCODE_F))
game.ui.isFullscreenToggled = true;
app.commands.push_back({"", COMMAND_FULLSCREEN});
if (!isTyping && event.key.scancode == SDL_SCANCODE_M)
{
game.settings.isMuted = !game.settings.isMuted;
game.audio.gain_set(game.settings.isMuted ? 0 : game.settings.volume / PERCENT);
app.settings.isMuted = !app.settings.isMuted;
app.audio.gain_set(app.settings.isMuted ? 0 : app.settings.volume / PERCENT);
}
if (event.key.scancode == SDL_SCANCODE_GRAVE && game.settings.isConsole)
game.console.isOpen = !game.console.isOpen;
if (event.key.scancode == SDL_SCANCODE_GRAVE && app.settings.isConsole)
app.console.isOpen = !app.console.isOpen;
}
}
// Carries out what the UI, console and shortcuts asked for this tick.
static void commands_handle()
{
Commands commands = std::move(app.commands);
app.commands.clear();
for (Command& command : commands)
{
if (command.type == COMMAND_CONTINUE || command.type == COMMAND_NEW_GAME)
play_start(command.character, command.type == COMMAND_NEW_GAME ? GAME_NEW : GAME_CONTINUE);
if (command.type == COMMAND_MENU)
play_exit();
if (command.type == COMMAND_SAVE && app.screen == SCREEN_PLAY)
app.play.save();
if (command.type == COMMAND_RELOAD)
characters_reload();
if (command.type == COMMAND_FULLSCREEN)
SDL_SetWindowFullscreen(app.window, !(SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN));
}
}
static void update()
{
Play& play = game.play;
Ui& ui = game.ui;
Vec2 screen = {ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y};
if (game.screen == SCREEN_SELECT)
{
ui.select_update(game.characters, game.settings, game.renderer, game.audio);
if (ui.isRefresh)
characters_reload();
if (ui.isNewGame || ui.isContinue)
play_start(ui.characterIndex, ui.isNewGame ? GAME_NEW : GAME_CONTINUE);
ui.isNewGame = ui.isContinue = false;
}
ImVec2 display = ImGui::GetIO().DisplaySize;
if (app.screen == SCREEN_SELECT)
app.ui.select_update(app.characters, app.settings);
else
{
ui.play_update(play, game.settings, game.renderer, game.audio);
play.update(screen);
if (std::exchange(ui.isSave, false))
play.save();
if (std::exchange(ui.isReturn, false))
play_exit();
app.ui.play_update(app.play, app.settings);
app.play.update({display.x, display.y});
}
app.console.update(app.screen == SCREEN_PLAY ? &app.play : nullptr, app.characters);
commands_handle();
Console& console = game.console;
console.update(game.screen == SCREEN_PLAY ? &play : nullptr, game.characters);
if (console.action == ACTION_MENU)
play_exit();
if (console.action == ACTION_CHARACTER || console.action == ACTION_NEW_GAME)
play_start(character_get(console.target), console.action == ACTION_NEW_GAME ? GAME_NEW : GAME_CONTINUE);
if (console.action == ACTION_RELOAD)
characters_reload();
if (std::exchange(ui.isFullscreenToggled, false))
SDL_SetWindowFullscreen(game.window, !(SDL_GetWindowFlags(game.window) & SDL_WINDOW_FULLSCREEN));
ui.isFullscreen = SDL_GetWindowFlags(game.window) & SDL_WINDOW_FULLSCREEN;
if (!ui.isFullscreen)
app.ui.isFullscreen = SDL_GetWindowFlags(app.window) & SDL_WINDOW_FULLSCREEN;
if (!app.ui.isFullscreen)
{
SDL_GetWindowSize(game.window, &game.settings.width, &game.settings.height);
SDL_GetWindowPosition(game.window, &game.settings.x, &game.settings.y);
SDL_GetWindowSize(app.window, &app.settings.width, &app.settings.height);
SDL_GetWindowPosition(app.window, &app.settings.x, &app.settings.y);
}
bool isCursorHidden = game.screen == SCREEN_PLAY && play.character->cursor && !console.isOpen;
bool isCursorHidden = app.screen == SCREEN_PLAY && app.play.cursor.anm2 && !app.console.isOpen;
if (isCursorHidden != app.isCursorHidden)
isCursorHidden ? SDL_HideCursor() : SDL_ShowCursor();
app.isCursorHidden = isCursorHidden;
}
static void render()
{
Play& play = game.play;
Renderer& renderer = game.renderer;
bool isPlaying = game.screen == SCREEN_PLAY;
Play& play = app.play;
Renderer& renderer = app.renderer;
bool isPlaying = app.screen == SCREEN_PLAY;
ImVec2 screen = ImGui::GetIO().DisplaySize;
Color background = isPlaying && game.settings.isCharacterColor ? play.character->color : game.settings.color;
Color background = isPlaying && app.settings.isCharacterColor ? play.character->color : app.settings.color;
renderer.canvas_set(nullptr, (int)screen.x, (int)screen.y, background);
if (isPlaying)
play.render(renderer, {screen.x, screen.y});
renderer.flush();
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (isPlaying && play.character->cursor && !game.console.isOpen)
if (app.isCursorHidden)
play.cursor.render(renderer, {});
renderer.flush();
SDL_GL_SwapWindow(game.window);
SDL_GL_SwapWindow(app.window);
}
// Runs a tick (sixty a second) once its time comes.
static void loop()
{
Uint64 now = SDL_GetTicks();
if (now - game.tickPrevious < TICK_MILLISECONDS)
if (now - app.tickPrevious < TICK_MILLISECONDS)
return SDL_Delay(1);
game.tickPrevious = now;
app.tickPrevious = now;
events_handle();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL3_NewFrame();
@@ -247,24 +252,24 @@ static void loop()
static void quit()
{
play_exit();
game.settings.save();
app.settings.save();
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
SDL_GL_DestroyContext(game.context);
SDL_DestroyWindow(game.window);
SDL_GL_DestroyContext(app.context);
SDL_DestroyWindow(app.window);
SDL_Quit();
}
int main(int, char**)
{
init();
if (!game.isRunning)
if (!app.isRunning)
return 1;
#ifdef SDL_PLATFORM_EMSCRIPTEN
emscripten_set_main_loop(loop, 0, true);
#else
while (game.isRunning)
while (app.isRunning)
loop();
#endif
quit();
+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,
ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow,
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 CHEAT_TIME = 5;
constexpr float FOCUS_MENU_WIDTH = 0.3f;
@@ -17,18 +20,14 @@ constexpr float SHAKE_DECAY = 0.97f;
constexpr float SHAKE_EPSILON = 0.1f;
constexpr float SHAKE_LERP = 0.72f;
constexpr float SHAKE_MAGNITUDE = 0.1f;
constexpr float TALK_STEP = 0.5f;
constexpr int OVERRIDE_TALK = 0;
constexpr int OVERRIDE_BLINK = 1;
constexpr int OVERRIDE_EXPAND = 2;
// Keeps a value within a range given either way round, putting what isn't a number at its low end.
static float value_clamp(float value, float low, float high)
struct SavedItem
{
if (low > high)
std::swap(low, high);
return std::isfinite(value) ? std::clamp(value, low, high) : low;
}
std::string name;
Vec2 position;
Vec2 velocity;
float rotation = 0;
};
static int zoom_index_get(float zoom)
{
@@ -39,116 +38,99 @@ static int zoom_index_get(float zoom)
return index;
}
// Starts a character, from its save unless it's a new game. A save is the body's stats, the play's own fields (see
// visit), the items found (one "discovered" line each) and those lying in the world ("item" lines of position,
// velocity, rotation and name).
void Play::set(Character& played, Game game, Renderer& renderer, Audio& sound)
{
played.load(renderer, sound);
*this = Play();
character = &played;
audio = &sound;
camera.zoom = 0;
std::vector<std::string> found;
std::vector<SavedItem> saved;
if (game == GAME_CONTINUE)
progress.load(played.save_path_get());
if (!progress.isValid)
{
progress.weight = played.weight;
progress.capacity = played.capacity;
progress.digestionRate = played.digestionRate;
}
is_file_read(played.save_path_get(), [&](const std::string& key, const char* value) {
field_line_read(body.stats, key, value);
field_line_read(*this, key, value);
SavedItem item;
int length = 0;
if (key == KEY_DISCOVERED)
found.emplace_back(value);
else if (key == KEY_ITEM && std::sscanf(value, "%f %f %f %f %f %n", &item.position.x, &item.position.y,
&item.velocity.x, &item.velocity.y, &item.rotation, &length) == 5)
saved.push_back({value + length, item.position, item.velocity, item.rotation});
});
body.set(played, sound, game);
area_set();
actor = Actor(played.anm2);
actor.overrides.resize(OVERRIDE_EXPAND);
actor.overrides[OVERRIDE_TALK].id = played.talkDestination;
actor.overrides[OVERRIDE_BLINK].id = played.blinkDestination;
for (ExpandArea& expandArea : played.expandAreas)
Catalog& catalog = played.catalog;
items.discovered.assign(catalog.items.size(), false);
for (const std::string& name : found)
if (int id = catalog.item_get(name); id >= 0)
items.discovered[id] = true;
for (SavedItem& item : saved)
{
actor.overrides.push_back({.id = expandArea.layer});
actor.overrides.push_back({.id = expandArea.null, .target = TARGET_NULL});
int id = catalog.item_get(item.name);
if (id >= 0)
items.add(*this, id, item.position, item.velocity, item.rotation);
else
SDL_Log("Saved item not found: %s", item.name.c_str());
}
values_clamp();
stage = stage_get(progress.weight);
bool isRolled = game == GAME_NEW && played.alternateTexture && is_chance(played.alternateChance);
progress.isAlternate |= isRolled;
if (isRolled)
audio->play(played.alternateSound);
if (progress.isAlternate && played.alternateSheet < (int)actor.sheets.size())
actor.sheets[played.alternateSheet] = played.alternateTexture;
cursorType = played.interactTypes.empty() ? -1 : 0;
if (played.cursor)
if (played.cursor.anm2)
{
cursor = Actor(played.cursor);
autoCursor = Actor(played.cursor);
cursor = Actor(played.cursor.anm2);
autoCursor = Actor(played.cursor.anm2);
autoCursor.isVisible = false;
}
spawnDelay = progress.spawnDelay;
discovered.assign(played.items.size(), false);
auto item_get = [&](const std::string& name) {
for (int i = 0; i < (int)played.items.size(); i++)
if (played.items[i].name == name)
return i;
return -1;
};
for (const std::string& name : progress.discovered)
if (int id = item_get(name); id >= 0)
discovered[id] = true;
for (SavedItem& saved : progress.items)
if (int id = item_get(saved.name); id >= 0)
item_add(id, saved.position, saved.velocity, saved.rotation);
isPostgame = progress.isPostgame || stage >= (int)played.stages.size();
isPostgame |= body.stage >= (int)played.stages.size();
#ifdef DEBUG
isCheats = true;
#endif
isCheats |= isPostgame;
int areaIndex = played.area_get(stage);
if (areaIndex >= 0)
area = Actor(played.areas[areaIndex].anm2);
bounds = areaIndex >= 0 && is_rect_valid(played.areas[areaIndex].bounds) ? played.areas[areaIndex].bounds
: WORLD_BOUNDS;
idle_play();
actor.update(*audio);
isFocusPending = true;
camera.isFocusPending = camera.zoom < ZOOM_LADDER[0];
camera.zoom = camera.isFocusPending ? ZOOM_LADDER[0] : ZOOM_LADDER[zoom_index_get(camera.zoom)];
if (game == GAME_NEW)
text.set(*this, played.dialogue.hook_get("Start"), TEXT_UNINTERRUPTIBLE);
text.set(*this, played.dialogue.hook_get(HOOK_START), TEXT_UNINTERRUPTIBLE);
}
void Play::save()
{
if (!character)
return;
progress.pan = pan;
progress.zoom = zoom;
progress.spawnDelay = spawnDelay;
progress.isPostgame = isPostgame;
progress.items.clear();
for (ItemEntity& entity : items)
progress.items.push_back(
{character->items[entity.id].name, entity.actor.position, entity.velocity, entity.rotation_get()});
progress.discovered.clear();
for (int i = 0; i < (int)discovered.size(); i++)
if (discovered[i])
progress.discovered.push_back(character->items[i].name);
progress.save(character->save_path_get());
toast_add(STRING_ToastSaving);
std::string text = fields_write(body.stats) + fields_write(*this);
for (int i = 0; i < (int)items.discovered.size(); i++)
if (items.discovered[i])
text += std::string(KEY_DISCOVERED) + "=" + character->catalog.items[i].name + "\n";
for (ItemEntity& entity : items.list)
text += std::string(KEY_ITEM) + "=" + field_write(entity.actor.position) + " " + field_write(entity.velocity) +
" " + field_write(entity.actor.rotation.value_or(0)) + " " + character->catalog.items[entity.id].name +
"\n";
file_write(character->save_path_get(), text);
toast_add("Saving...");
}
void Play::toast_add(StringId message)
// Shows the area of the body's stage, with its bounds.
void Play::area_set()
{
toasts.push_back({character->strings[message], TOAST_LIFE});
int index = character->area_get(body.stage);
if (index >= 0 && area.anm2 != character->areas[index].anm2)
area = Actor(character->areas[index].anm2);
bounds = index >= 0 && is_rect_valid(character->areas[index].bounds) ? character->areas[index].bounds : WORLD_BOUNDS;
}
void Play::shake_add(float magnitude, int time)
void Play::toast_add(const std::string& message)
{
shakeTimeMax = std::max(1, time);
shakeTime = shakeTimeMax;
shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)};
toasts.push_back({message, TOAST_LIFE});
}
Vec2 Play::world_get(Vec2 screen)
Actor& Play::pointer_actor_get()
{
return pan + screen / (zoom / PERCENT);
return autofeed.isActive ? autoCursor : cursor;
}
Focus Play::focus_get()
@@ -158,254 +140,209 @@ Focus Play::focus_get()
return isMenuOpen && isToolsOpen ? FOCUS_BOTH : isMenuOpen ? FOCUS_MENU : isToolsOpen ? FOCUS_TOOLS : FOCUS_CENTER;
}
// Zooms and pans so the character fills the view, leaving room for the open panels.
void Play::focus(Vec2 screenSize)
{
Rect rect = actor.bounds_get();
if (!is_rect_valid(rect))
camera.focus(body.actor.bounds_get(), screenSize, focus_get());
}
void Play::particles_spawn(const std::vector<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;
rect = rect_center(rect, FOCUS_PADDING);
float factor = std::min(screenSize.x / rect.w, screenSize.y / rect.h);
for (ImGuiKey key : CHEAT_KEYS)
{
if (!ImGui::IsKeyPressed(key, false))
continue;
cheatIndex = key == CHEAT_CODE[cheatIndex] ? cheatIndex + 1 : key == CHEAT_CODE[0] ? 1 : 0;
cheatTime = (float)ImGui::GetTime();
if (cheatIndex < (int)std::size(CHEAT_CODE))
continue;
isCheats = true;
cheatIndex = 0;
toast_add("Cheats unlocked!");
audio->play(character->sounds[SOUND_CHEATS]);
}
if (cheatIndex > 0 && ImGui::GetTime() - cheatTime > CHEAT_TIME)
cheatIndex = 0;
}
// Answers what the body did this tick: dialogue for digesting and stage ups, and the ending at the final stage.
void Play::events_handle(Vec2 screenSize)
{
Dialogue& dialogue = character->dialogue;
for (Event event : events)
{
if (event == EVENT_DIGESTED && !text.isScene && text.is_interruptible())
text.set(*this, dialogue.hook_get(HOOK_DIGEST));
if (event == EVENT_STAGE_UP && !text.isScene)
text.set(*this, dialogue.hook_get(HOOK_STAGE_UP));
if (event != EVENT_STAGE_FINAL || isPostgame || isPostgamePending)
continue;
isPostgame = isCheats = !dialogue.is_hook(HOOK_END);
if (isPostgame)
continue;
text.set(*this, dialogue.hook_get(HOOK_END), TEXT_UNINTERRUPTIBLE);
isMenuOpen = isToolsOpen = false;
body.stats.calories = 0;
body.stats.digestionProgress = 0;
items.drop();
isPostgamePending = true;
focus(screenSize);
}
events.clear();
}
void Play::update(Vec2 screenSize)
{
ImGuiIO& io = ImGui::GetIO();
if (std::exchange(camera.isFocusPending, false))
focus(screenSize);
body.stats.playtime += TICK;
cheats_update();
text.update(*this);
if (isPostgamePending && (!text.entry || (text.entry->isTerminal && text.isFinished)) && !text.isScene)
{
isMenuOpen = isPostgame = isCheats = true;
isPostgamePending = false;
focus(screenSize);
}
if (autofeed.isEnabled)
cursorType = autofeed.type_get(*this);
Pointer pointer;
pointer.position = camera.world_get({io.MousePos.x, io.MousePos.y});
pointer.type = cursorType;
pointer.isCaptured = io.WantCaptureMouse;
pointer.isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
pointer.isDown = ImGui::IsMouseDown(ImGuiMouseButton_Left);
pointer.isReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left);
pointer.isRightClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Right);
Pointer itemPointer = pointer;
Pointer interactPointer = pointer;
autofeed.isActive = false;
if (text.isScene)
itemPointer = interactPointer = {.position = pointer.position, .isCaptured = true};
else
autofeed.update(*this, itemPointer, interactPointer);
items.update(*this, itemPointer);
interactions.update(*this, interactPointer);
if (cursor.anm2 && !autofeed.isActive && pointer.isClicked && !items.isHovered && !interactions.isHovering &&
cursorState == CURSOR_DEFAULT)
cursor.play(character->cursor.click, TRANSITION_FORCE, INTERRUPT_ALWAYS);
body.update(events);
events_handle(screenSize);
area_set();
area.update(*audio);
for (int i = 0; i < (int)particles.size(); i++)
{
particles[i].update(*audio);
if (!particles[i].isPlaying)
particles.erase(particles.begin() + i--);
}
for (Actor* each : {&cursor, &autoCursor})
{
bool isActive = (each == &autoCursor) == autofeed.isActive;
if ((!isActive || cursorState == CURSOR_DEFAULT) && !each->pending)
each->play(character->cursor.idle, TRANSITION_IF_IDLE);
each->update(*audio);
}
cursorState = CURSOR_DEFAULT;
cursor.position = {io.MousePos.x, io.MousePos.y};
camera.update(*this, screenSize);
for (int i = 0; i < (int)toasts.size(); i++)
if (--toasts[i].life <= 0)
toasts.erase(toasts.begin() + i--);
if ((autosaveTime += TICK) > AUTOSAVE_TIME)
{
save();
autosaveTime = 0;
}
}
void Play::render(Renderer& renderer, Vec2 screenSize)
{
Affine view = camera.view_get(screenSize);
area.render(renderer, view);
body.actor.render(renderer, view);
if (!text.isScene)
for (ItemEntity& entity : items.list)
entity.actor.render(renderer, view);
if (autofeed.isActive)
autoCursor.render(renderer, view);
for (Actor& particle : particles)
particle.render(renderer, view);
}
Vec2 Camera::world_get(Vec2 screen)
{
return pan + screen / (zoom / PERCENT);
}
Affine Camera::view_get(Vec2 screenSize)
{
float scale = zoom / PERCENT;
return {scale, 0, 0, scale, -pan.x * scale + shake.x * screenSize.x, -pan.y * scale + shake.y * screenSize.y};
}
void Camera::shake_add(float magnitude, int time)
{
shakeTimeMax = std::max(1, time);
shakeTime = shakeTimeMax;
shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)};
}
// Zooms and pans so a target fills the view, leaving room for the open panels.
void Camera::focus(Rect target, Vec2 screenSize, Focus panels)
{
if (!is_rect_valid(target))
return;
target = rect_center(target, FOCUS_PADDING);
float factor = std::min(screenSize.x / target.w, screenSize.y / target.h);
zoom = ZOOM_LADDER[zoom_index_get(std::clamp(factor * PERCENT, ZOOM_LADDER[0], std::end(ZOOM_LADDER)[-1]))];
factor = zoom / PERCENT;
pan = rect_middle(rect) - screenSize / factor / 2;
Focus panels = focus_get();
pan = rect_middle(target) - screenSize / factor / 2;
if (panels == FOCUS_MENU || panels == FOCUS_BOTH)
pan.x += screenSize.x * FOCUS_MENU_WIDTH / factor / 2;
if (panels == FOCUS_TOOLS || panels == FOCUS_BOTH)
pan.x -= screenSize.x * FOCUS_TOOLS_WIDTH / factor / 2;
}
float Play::capacity_max_get()
{
return progress.capacity * character->overstuffedMultiplier;
}
float Play::capacity_percent_get()
{
float capacityMax = capacity_max_get();
return capacityMax > 0 ? progress.calories / capacityMax : 0;
}
bool Play::is_over_capacity()
{
return progress.calories > progress.capacity;
}
int Play::stage_get(float weight)
{
std::vector<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)
// Pans with the middle mouse (or control and the left), zooms through the ladder with the wheel toward the mouse, and
// settles the screen shake.
void Camera::update(Play& play, Vec2 screenSize)
{
ImGuiIO& io = ImGui::GetIO();
Cursor& cursor = play.character->cursor;
if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive())
{
if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) ||
(ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl)))
{
cursor.play(character->cursorPan);
play.cursor.play(cursor.pan);
pan = pan - Vec2{io.MouseDelta.x, io.MouseDelta.y} / (zoom / PERCENT);
}
if (io.MouseWheel != 0)
{
Vec2 mouse = {io.MousePos.x, io.MousePos.y};
Vec2 world = world_get(mouse);
cursor.play(character->cursorZoom);
play.cursor.play(cursor.zoom);
int index = std::clamp(zoom_index_get(zoom) + (io.MouseWheel > 0 ? 1 : -1), 0,
(int)std::size(ZOOM_LADDER) - 1);
zoom = ZOOM_LADDER[index];
@@ -413,7 +350,7 @@ void Play::camera_update(Vec2 screenSize)
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Home))
focus(screenSize);
play.focus(screenSize);
if (shakeTime > 0)
{
@@ -430,152 +367,3 @@ void Play::camera_update(Vec2 screenSize)
shake = {};
}
}
// The Konami code unlocks the cheats menu, keyed within a few seconds of each press.
void Play::cheats_update()
{
if (isCheats)
return;
for (ImGuiKey key : {ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_B,
ImGuiKey_A})
{
if (!ImGui::IsKeyPressed(key, false))
continue;
cheatIndex = key == CHEAT_CODE[cheatIndex] ? cheatIndex + 1 : key == CHEAT_CODE[0] ? 1 : 0;
cheatTime = (float)ImGui::GetTime();
if (cheatIndex < (int)std::size(CHEAT_CODE))
continue;
isCheats = true;
cheatIndex = 0;
toast_add(STRING_ToastCheatsUnlocked);
audio->play(character->sounds[SOUND_CHEATS]);
}
if (cheatIndex > 0 && ImGui::GetTime() - cheatTime > CHEAT_TIME)
cheatIndex = 0;
}
void Play::particles_spawn(const std::vector<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"
constexpr float DIGESTION_MAX = 100;
constexpr float TICK = 1 / TICK_RATE;
constexpr int TOAST_LIFE = 180;
constexpr float ZOOM_LADDER[] = {100, 115, 130, 150, 175, 200, 235, 275, 325, 400};
@@ -14,12 +15,6 @@ enum Game
GAME_CONTINUE
};
enum Hold
{
HOLD_RELEASE,
HOLD_KEEP
};
enum CursorState
{
CURSOR_DEFAULT,
@@ -36,18 +31,32 @@ enum Focus
FOCUS_BOTH
};
enum Hold
{
HOLD_RELEASE,
HOLD_KEEP
};
// What happened to the body in a tick, answered once it's done.
enum Event
{
EVENT_DIGESTED,
EVENT_STAGE_UP,
EVENT_STAGE_FINAL
};
// The mouse as items and interact areas see it this tick: the player's, or the autofeed's stand-in.
struct Pointer
{
Vec2 position;
int target = -1;
int type = -1;
bool isAutomated = false;
bool isCaptured = false;
bool isClicked = false;
bool isDown = false;
bool isReleased = false;
bool isRightClicked = false;
bool isAutomated = false;
};
struct ItemEntity
@@ -56,11 +65,6 @@ struct ItemEntity
Vec2 velocity;
float angularVelocity = 0;
int id = 0;
float& rotation_get()
{
return actor.overrides[0].rotation;
}
};
struct Toast
@@ -69,120 +73,181 @@ struct Toast
int life = 0;
};
class Renderer;
class Audio;
struct Settings;
class Play;
// A character being played: its body (weight, digestion, stages), the items in the world, what the cursor does to
// both, the autofeed, the camera and the effects.
// The character's body: its stats, digestion and stages, and the animations they choose.
class Body
{
public:
Stats stats;
Actor actor;
std::string idleOverride;
std::string suffix;
Character* character = nullptr;
Audio* audio = nullptr;
float blinkTime = 0;
float talkTime = 0;
int stage = 0;
bool isBlinking = false;
bool isStageUp = false;
bool isTalking = false;
void set(Character& played, Audio& sound, Game game);
void update(std::vector<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
{
public:
Character* character = nullptr;
Audio* audio = nullptr;
Progress progress;
Autofeed autofeed;
Body body;
Camera camera;
Interactions interactions;
Items items;
Text text;
Actor actor;
Actor area;
Actor autoCursor;
Actor cursor;
std::string idleOverride;
std::vector<Actor> particles;
std::vector<ItemEntity> items;
std::vector<Event> events;
std::vector<Toast> toasts;
std::vector<int> dialogueCooldowns;
std::vector<int> spawnQueue;
std::vector<bool> discovered;
Rect bounds;
Vec2 autoVelocity;
Vec2 itemPointerPrevious;
Vec2 pan;
Vec2 pointerPrevious;
Vec2 shake;
float autoAngle = 0;
float autosaveTime = 0;
float blinkTime = 0;
float cheatTime = 0;
float talkTime = 0;
float zoom = ZOOM_LADDER[0];
int autoClickCooldown = 0;
int autoTarget = -1;
int cheatIndex = 0;
int cursorType = -1;
int heldItem = -1;
int interactArea = -1;
int eatAnimation = -1;
int eatEvent = -1;
int eatItem = -1;
int shakeTime = 0;
int shakeTimeMax = 0;
int spawnDelay = 0;
int stage = 0;
int tooltipItem = -1;
CursorState cursorState = CURSOR_DEFAULT;
Rect eatRect = {NAN, NAN, NAN, NAN};
bool isAutofeed = false;
bool isAutofeedActive = false;
bool isAutoDigestHeld = false;
bool isAutoItemHeld = false;
bool isBlinking = false;
bool isCheats = false;
bool isEatTextFull = false;
bool isEatTextPending = false;
bool isFocusPending = false;
bool isHoldInteracting = false;
bool isHovering = false;
bool isInteracting = false;
bool isItemHovered = false;
bool isJustItemHeld = false;
bool isJustDigested = false;
bool isJustStageFinal = false;
bool isJustStageUp = false;
bool isMenuOpen = true;
bool isPostgame = false;
bool isPostgamePending = false;
bool isStageUp = false;
bool isTalking = false;
bool isToolsOpen = false;
void set(Character& played, Game game, Renderer& renderer, Audio& sound);
void save();
void update(Vec2 screenSize);
void render(Renderer& renderer, Vec2 screenSize);
void toast_add(StringId message);
void shake_add(float magnitude, int time);
void focus(Vec2 screenSize);
Vec2 world_get(Vec2 screen);
void toast_add(const std::string& message);
void particles_spawn(const std::vector<std::string>& names, Vec2 position);
Actor& pointer_actor_get();
Focus focus_get();
float capacity_max_get();
float capacity_percent_get();
bool is_over_capacity();
int stage_get(float weight);
float stage_progress_get();
float stage_threshold_get(int index);
std::string idle_get();
std::string suffix_get();
void body_play(const std::string& animation, Transition transition = TRANSITION_NORMAL,
Interrupt interrupt = INTERRUPT_IF_ALLOWED);
void idle_play();
void idle_override_set(const std::string& animation);
void values_clamp();
void item_add(int id, Vec2 position, Vec2 velocity = {}, float rotation = 0);
int spawn_delay_get();
bool is_spawn_possible();
void spawn_queue_add(int id);
void spawn_queue_add();
void particles_spawn(const std::vector<std::string>& names, Vec2 position);
Rect eat_rect_get(const EatArea& eatArea);
template <class Visitor> void visit(Visitor&& visitor)
{
visitor("pan", camera.pan);
visitor("postgame", isPostgame);
visitor("spawn_delay", items.spawnDelay);
visitor("zoom", camera.zoom);
}
private:
void body_update();
void camera_update(Vec2 screenSize);
void area_set();
void cheats_update();
void interact_update(Pointer pointer);
void items_update(Pointer pointer);
void autofeed_update(Pointer& itemPointer, Pointer& interactPointer);
void item_drop(Hold hold = HOLD_RELEASE);
void events_handle(Vec2 screenSize);
};
+2
View File
@@ -35,6 +35,8 @@ class Renderer
{
public:
Texture white;
bool isShowLayers = false;
bool isShowNulls = false;
bool isValid = false;
void init();
+2 -174
View File
@@ -1,96 +1,13 @@
#include "save.h"
#include <SDL3/SDL.h>
#include <cstdio>
#include <type_traits>
#ifdef SDL_PLATFORM_EMSCRIPTEN
#include <emscripten.h>
#endif
constexpr const char* KEY_DISCOVERED = "discovered";
constexpr const char* KEY_ITEM = "item";
constexpr const char* SETTINGS_FILE = "settings.txt";
static void value_read(const char* text, float& value)
{
value = (float)SDL_atof(text);
}
static void value_read(const char* text, int& value)
{
value = SDL_atoi(text);
}
static void value_read(const char* text, bool& value)
{
value = SDL_atoi(text);
}
static void value_read(const char* text, Measurement& value)
{
value = (Measurement)SDL_atoi(text);
}
static void value_read(const char* text, std::string& value)
{
value = text;
}
static void value_read(const char* text, Vec2& value)
{
SDL_sscanf(text, "%f %f", &value.x, &value.y);
}
static void value_read(const char* text, Color& value)
{
SDL_sscanf(text, "%f %f %f", &value.r, &value.g, &value.b);
}
static std::string value_write(float value)
{
return std::to_string(value);
}
static std::string value_write(int value)
{
return std::to_string(value);
}
static std::string value_write(const std::string& value)
{
return value;
}
static std::string value_write(Vec2 value)
{
return value_write(value.x) + " " + value_write(value.y);
}
static std::string value_write(Color value)
{
return value_write(value.r) + " " + value_write(value.g) + " " + value_write(value.b);
}
// Calls line with each key and value of a key=value text file. Returns whether it was there.
template <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)
void file_write(const std::string& path, const std::string& text)
{
SDL_CreateDirectory(path.substr(0, path.find_last_of('/')).c_str());
if (!SDL_SaveFile(path.c_str(), text.data(), text.size()))
@@ -100,48 +17,9 @@ static void file_write(const std::string& path, const std::string& text)
#endif
}
// Reads every field a visitable struct names from a key=value file, and writes them back.
template <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()
{
fields_read(pref_path_get(SETTINGS_FILE), *this);
is_fields_read(pref_path_get(SETTINGS_FILE), *this);
}
void Settings::save()
@@ -159,53 +37,3 @@ void Settings::reset()
isCharacterColor = defaults.isCharacterColor;
isMuted = defaults.isMuted;
}
template <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
#include "common.h"
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_video.h>
#include <cstdio>
#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
{
@@ -11,6 +16,76 @@ enum Measurement
MEASUREMENT_IMPERIAL
};
void file_write(const std::string& path, const std::string& text);
// Calls line with each key and value of a file. Returns whether it was there.
template <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.
struct Settings
{
@@ -29,23 +104,26 @@ struct Settings
void load();
void save();
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 capacity = 0;
float digestionProgress = 0;
@@ -53,17 +131,25 @@ struct Progress
float playtime = 0;
float totalCalories = 0;
float weight = 0;
float zoom = 0;
int digestionTimer = 0;
int spawnDelay = 0;
int totalConsumed = 0;
int totalSpawned = 0;
bool isAlternate = false;
bool isDigesting = false;
bool isPostgame = false;
bool isValid = false;
void load(const std::string& path);
void save(const std::string& path);
template <class Visitor> void visit(Visitor&& visitor);
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
};
// Hover and select sounds (from the character being played, or none) and the pulsing border on hovered widgets.
void widget_sounds_set(Audio* audio, const Sounds* hover, const Sounds* select);
void widget_fx();
// Passes a widget's result through after giving it its sounds and hover border.
inline bool is_fx(bool result)
enum CommandType
{
widget_fx();
return result;
}
COMMAND_CONTINUE,
COMMAND_NEW_GAME,
COMMAND_MENU,
COMMAND_SAVE,
COMMAND_RELOAD,
COMMAND_FULLSCREEN
};
inline ImVec4 imvec4_get(Color color)
// A request, from the UI or the console, for the game to change what's running. Character names a character's folder.
struct Command
{
return {color.r, color.g, color.b, color.a};
}
std::string character;
CommandType type = COMMAND_MENU;
};
using Commands = std::vector<Command>;
void style_set(Color color, float rounding = 0);
float row_width_get(int count);
void tooltip_set(const char* text, float size = FONT_NORMAL);
void triangle_draw(ImVec2 position, ImVec2 size, Direction direction);
// The menus, tools, toasts and select screen.
// The menus, tools, dialogue box, toasts and select screen.
class Ui
{
public:
std::vector<Actor> itemActors;
std::vector<Canvas> itemCanvases;
std::vector<ImVec2> itemSizes;
Actor previewActor;
Canvas previewCanvas;
Progress selectedProgress;
Stats selectedStats;
Audio* audio = nullptr;
Character* catalogCharacter = nullptr;
Character* character = nullptr;
Commands* commands = nullptr;
Renderer* renderer = nullptr;
float menuSlide = 1;
float toolsSlide = 0;
int characterIndex = 0;
int progressIndex = -1;
int selectedIndex = -1;
int selectedItem = -1;
bool isContinue = false;
bool isFullscreen = false;
bool isFullscreenToggled = false;
bool isNewGame = false;
bool isNewGameWarning = false;
bool isPreview = false;
bool isRefresh = false;
bool isReturn = false;
bool isSave = false;
bool isSelectedSaved = false;
void play_update(Play& play, Settings& settings, Renderer& renderer, Audio& audio);
void select_update(std::vector<Character>& characters, Settings& settings, Renderer& renderer, Audio& audio);
void settings_update(Settings& settings, Audio& audio, const Character* character);
void play_update(Play& play, Settings& settings);
void select_update(std::vector<Character>& characters, Settings& settings);
private:
bool is_fx(bool result);
void command_add(CommandType type, const std::string& key = "");
void settings_draw(Settings& settings);
void interact_tab_draw(Play& play, Settings& settings);
void catalog_tab_draw(Play& play);
void cheats_tab_draw(Play& play);
void tools_draw(Play& play, ImVec2 position, ImVec2 size);
void bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction,
const char* openTooltip, const char* closeTooltip);
void text_draw(Play& play, ImVec2 screen);
void toasts_draw(Play& play);
};