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
+2504 -2431

No files matched your search

+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;
}