#include "play.h" #include constexpr float AUTO_ACCELERATION = 0.45f; constexpr float AUTO_ANGLE_STEP = 0.08f; constexpr float AUTO_BRAKING = 2; constexpr float AUTO_CALORIE_EPSILON = 0.001f; constexpr float AUTO_DIGEST_ACCELERATION = 0.15f; constexpr float AUTO_DIGEST_RADIUS = 0.25f; constexpr float AUTO_DIGEST_SPEED = 3; constexpr float AUTO_SCALE_MAX = 2; constexpr float AUTO_SCALE_MIN = 0.5f; constexpr float AUTO_SCALE_AREA = 512.0f * 512.0f; constexpr float AUTO_SPEED = 9; constexpr float AUTO_TARGET_EPSILON = 0.001f; constexpr int AUTO_CLICK_COOLDOWN = 45; constexpr float HOLD_SPIN = 0.1f; constexpr float ROTATION_GROUND_DAMPING = 0.85f; constexpr float ROTATION_MAX = 90; constexpr float ROTATION_RETURN_SPEED = 120; constexpr float THROW_THRESHOLD = 10; void Play::item_add(int id, Vec2 position, Vec2 velocity, float rotation) { 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; }