#include "play.h" #include "renderer.h" #include #include 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 float AUTOSAVE_TIME = 30; constexpr float CHEAT_TIME = 5; constexpr float FOCUS_MENU_WIDTH = 0.3f; constexpr float FOCUS_PADDING = 100; constexpr float FOCUS_TOOLS_WIDTH = 0.1f; 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) { if (low > high) std::swap(low, high); return std::isfinite(value) ? std::clamp(value, low, high) : low; } static int zoom_index_get(float zoom) { int index = 0; for (int i = 1; i < (int)std::size(ZOOM_LADDER); i++) if (std::abs(zoom - ZOOM_LADDER[i]) < std::abs(zoom - ZOOM_LADDER[index])) index = i; return index; } void Play::set(Character& played, Game game, Renderer& renderer, Audio& sound) { played.load(renderer, sound); *this = Play(); character = &played; audio = &sound; 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; } 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) { actor.overrides.push_back({.id = expandArea.layer}); actor.overrides.push_back({.id = expandArea.null, .target = TARGET_NULL}); } 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) { cursor = Actor(played.cursor); autoCursor = Actor(played.cursor); 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(); #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; if (game == GAME_NEW) text.set(*this, played.dialogue.hook_get("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); } void Play::toast_add(StringId message) { toasts.push_back({character->strings[message], TOAST_LIFE}); } void Play::shake_add(float magnitude, int time) { shakeTimeMax = std::max(1, time); shakeTime = shakeTimeMax; shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)}; } Vec2 Play::world_get(Vec2 screen) { return pan + screen / (zoom / PERCENT); } Focus Play::focus_get() { if (text.isScene) return FOCUS_CENTER; 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)) return; rect = rect_center(rect, FOCUS_PADDING); float factor = std::min(screenSize.x / rect.w, screenSize.y / rect.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(); 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& 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& 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) { ImGuiIO& io = ImGui::GetIO(); if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) { if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || (ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl))) { cursor.play(character->cursorPan); 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); int index = std::clamp(zoom_index_get(zoom) + (io.MouseWheel > 0 ? 1 : -1), 0, (int)std::size(ZOOM_LADDER) - 1); zoom = ZOOM_LADDER[index]; pan = world - mouse / (zoom / PERCENT); } } if (ImGui::IsKeyPressed(ImGuiKey_Home)) focus(screenSize); if (shakeTime > 0) { shakeTime--; float factor = (float)shakeTime / std::max(1, shakeTimeMax); float magnitude = std::max(vec2_length(shake) * SHAKE_DECAY, SHAKE_MAGNITUDE * factor); Vec2 target = Vec2{random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)} * factor; shake = vec2_lerp(shake, target, SHAKE_LERP); } else { shake = vec2_lerp(shake, {}, SHAKE_LERP); if (vec2_length(shake) < SHAKE_EPSILON) 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& 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); }