#include "play.h" constexpr const char* FULL_SUFFIX = "Full"; constexpr float RUB_JIGGLE = 0.3f; constexpr float SMACK_JIGGLE = 6; constexpr int HIT_STOP = 3; 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); if (entry.scaleAmplitude > 0) { body.jiggle_add(SMACK_JIGGLE); body.freeze = HIT_STOP; } bool isReady = cooldowns[i] <= 0; cooldowns[i] = std::max(0, entry.dialogueCooldown); Dialogue& dialogue = character.dialogue; std::string pool = entry.dialoguePool; if (body.is_over_capacity() && dialogue.pools.contains(pool + FULL_SUFFIX)) pool += FULL_SUFFIX; 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; bool isMoved = moved.x != 0 || moved.y != 0; if (entry.digestionHover > 0 && isMoved) body.jiggle_add(RUB_JIGGLE); if (entry.digestionHover > 0 && body.stats.calories > 0 && !body.stats.isDigesting && isMoved) 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(CURSOR_ANIMATION_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& catalog = play.character->catalog.items; std::vector& 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& 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; }