Files
shweets-sim/src/items.cpp
T

595 lines
24 KiB
C++

#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)
{
ItemEntity& entity = items.emplace_back(ItemEntity{character->item_actor_get(id), velocity, 0, id});
entity.actor.position = position;
entity.actor.overrides.push_back({.rotation = rotation, .target = TARGET_ROOT, .isRotation = true});
discovered[id] = true;
}
int Play::spawn_delay_get()
{
return std::max(1, (int)std::round(character->spawnDelay * std::pow(character->spawnDelayMultiplier, 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)
return 0;
return std::max(0.0f, play.character->rarities[item.rarity].weight);
}
bool Play::is_spawn_possible()
{
if ((int)items.size() >= character->spawnLimit)
return false;
for (int i = 0; i < (int)character->items.size(); i++)
if (spawn_weight_get(*this, i) > 0)
return true;
return false;
}
void Play::spawn_queue_add(int id)
{
if (id < 0 || id >= (int)character->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);
spawnQueue.push_back(id);
spawnDelay = spawn_delay_get();
}
// Queues an item picked at random by rarity, once the spawn delay is up.
void Play::spawn_queue_add()
{
if (spawnDelay > 0 || !is_spawn_possible())
return;
float total = 0;
for (int i = 0; i < (int)character->items.size(); i++)
total += spawn_weight_get(*this, 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);
}
// 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)
{
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) {
if (!pointer.isAutomated)
text.set(*this, data.dialogue.hook_get(hook));
};
auto held_release = [&](bool isThrown) {
item_drop();
active.play(data.cursorIdle);
if (!isThrown && !actor.pending)
idle_play();
};
if (text.isScene && heldItem >= 0)
{
held_release(false);
isEatTextPending = false;
}
if (isEatTextPending && eatAnimation == actor.animation && (!actor.isPlaying || actor.is_finished()))
{
text.set(*this, data.dialogue.hook_get(isEatTextFull ? "EatFull" : "Eat"));
isEatTextPending = false;
eatAnimation = -1;
}
bool wasItemHovered = isItemHovered;
isItemHovered = false;
tooltipItem = -1;
if (spawnDelay > 0)
spawnDelay--;
for (int id : spawnQueue)
{
Rect spawn = areaIndex >= 0 ? area.spawn : bounds;
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]);
}
item_add(id, position);
progress.totalSpawned++;
particles_spawn(data.summonParticles, position);
}
spawnQueue.clear();
if (heldItem >= 0 && heldItem < (int)items.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;
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)
{
Rect rect = eat_rect_get(eatArea);
bool isInside = is_point_in_rect(rect, held.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();
if (isAreaActive || isPendingActive)
{
if (isPending && eatArea.event >= 0 && 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;
break;
}
eatItem = heldItem;
eatEvent = eatArea.event;
if (isAreaActive)
eatRect = rect;
if (!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");
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())
{
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();
}
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]);
if (isThrown)
text_play("Throw");
held.velocity = held.velocity - delta;
held_release(isThrown);
}
if (removed >= 0)
items.erase(items.begin() + removed);
}
int moved = -1;
bool isTargetLocked = pointer.target >= 0 && pointer.target < (int)items.size();
float right = bounds.x + bounds.w;
float bottom = bounds.y + bounds.h;
for (int i = 0; i < (int)items.size(); i++)
{
ItemEntity& entity = items[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);
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;
if (pointer.isClicked)
{
audio->play(data.sounds[SOUND_GRAB]);
particles_spawn(data.grabParticles, pointer.position);
isJustItemHeld = true;
}
if (pointer.isDown)
{
active.play(data.cursorGrab);
cursorState = CURSOR_ACTION;
heldItem = 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;
moved = moved == i ? -1 : moved > i ? moved - 1 : moved;
items.erase(items.begin() + i--);
continue;
}
}
if (i != heldItem)
{
if (position.x <= bounds.x || position.x >= right)
{
position.x = position.x <= bounds.x ? bounds.x + 1 : right - 1;
velocity.x = -velocity.x * area.friction;
entity.angularVelocity = -entity.angularVelocity * area.friction;
rotation = -rotation;
audio->play(data.sounds[SOUND_BOUNCE]);
}
if (position.y <= bounds.y || position.y >= bottom)
{
if (position.y >= bottom && velocity.y <= gravity)
{
position.y = bottom;
velocity = {velocity.x * area.friction, 0};
rotation = std::remainder(rotation, 360.0f);
float step = ROTATION_RETURN_SPEED * ImGui::GetIO().DeltaTime;
rotation = std::abs(rotation) <= step ? 0 : rotation - std::copysign(step, rotation);
entity.angularVelocity *= ROTATION_GROUND_DAMPING;
}
else
{
velocity.y = -velocity.y;
entity.angularVelocity *= area.friction;
audio->play(data.sounds[SOUND_BOUNCE]);
}
velocity.y *= area.friction;
}
velocity.x *= area.airResistance;
velocity.y += gravity;
}
position = {std::clamp(position.x, bounds.x, right), std::clamp(position.y, bounds.y, bottom)};
}
if (moved >= 0 && moved < (int)items.size() - 1)
{
ItemEntity entity = std::move(items[moved]);
items.erase(items.begin() + moved);
items.push_back(std::move(entity));
heldItem = (int)items.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();
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;
}