#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 ImGuiKey CHEAT_KEYS[] = {ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGuiKey_LeftArrow, ImGuiKey_RightArrow, ImGuiKey_B, ImGuiKey_A}; constexpr const char* KEY_DISCOVERED = "discovered"; constexpr const char* KEY_ITEM = "item"; 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; struct SavedItem { std::string name; Vec2 position; Vec2 velocity; float rotation = 0; }; 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; } // Starts a character, from its save unless it's a new game. A save is the body's stats, the play's own fields (see // visit), the items found (one "discovered" line each) and those lying in the world ("item" lines of position, // velocity, rotation and name). void Play::set(Character& played, Game game, Renderer& renderer, Audio& sound) { played.load(renderer, sound); *this = Play(); character = &played; audio = &sound; camera.zoom = 0; std::vector found; std::vector saved; if (game == GAME_CONTINUE) is_file_read(played.save_path_get(), [&](const std::string& key, const char* value) { field_line_read(body.stats, key, value); field_line_read(*this, key, value); SavedItem item; int length = 0; if (key == KEY_DISCOVERED) found.emplace_back(value); else if (key == KEY_ITEM && std::sscanf(value, "%f %f %f %f %f %n", &item.position.x, &item.position.y, &item.velocity.x, &item.velocity.y, &item.rotation, &length) == 5) saved.push_back({value + length, item.position, item.velocity, item.rotation}); }); body.set(played, sound, game); area_set(); Catalog& catalog = played.catalog; items.discovered.assign(catalog.items.size(), false); for (const std::string& name : found) if (int id = catalog.item_get(name); id >= 0) items.discovered[id] = true; for (SavedItem& item : saved) { int id = catalog.item_get(item.name); if (id >= 0) items.add(*this, id, item.position, item.velocity, item.rotation); else SDL_Log("Saved item not found: %s", item.name.c_str()); } cursorType = played.interactTypes.empty() ? -1 : 0; if (played.cursor.anm2) { cursor = Actor(played.cursor.anm2); autoCursor = Actor(played.cursor.anm2); autoCursor.isVisible = false; } isPostgame |= body.stage >= (int)played.stages.size(); #ifdef DEBUG isCheats = true; #endif isCheats |= isPostgame; camera.isFocusPending = camera.zoom < ZOOM_LADDER[0]; camera.zoom = camera.isFocusPending ? ZOOM_LADDER[0] : ZOOM_LADDER[zoom_index_get(camera.zoom)]; if (game == GAME_NEW) text.set(*this, played.dialogue.hook_get(HOOK_START), TEXT_UNINTERRUPTIBLE); } void Play::save() { if (!character) return; std::string text = fields_write(body.stats) + fields_write(*this); for (int i = 0; i < (int)items.discovered.size(); i++) if (items.discovered[i]) text += std::string(KEY_DISCOVERED) + "=" + character->catalog.items[i].id + "\n"; for (ItemEntity& entity : items.list) text += std::string(KEY_ITEM) + "=" + field_write(entity.actor.position) + " " + field_write(entity.velocity) + " " + field_write(entity.actor.rotation.value_or(0)) + " " + character->catalog.items[entity.id].id + "\n"; file_write(character->save_path_get(), text); toast_add("Saving..."); } // Shows the area of the body's stage, with its bounds. void Play::area_set() { int index = character->area_get(body.stage); if (index >= 0 && area.anm2 != character->areas[index].anm2) area = Actor(character->areas[index].anm2); bounds = index >= 0 && is_rect_valid(character->areas[index].bounds) ? character->areas[index].bounds : WORLD_BOUNDS; } void Play::toast_add(const std::string& message) { toasts.push_back({message, TOAST_LIFE}); } Actor& Play::pointer_actor_get() { return autofeed.isActive ? autoCursor : cursor; } Focus Play::focus_get() { if (text.isScene) return FOCUS_CENTER; return isMenuOpen && isToolsOpen ? FOCUS_BOTH : isMenuOpen ? FOCUS_MENU : isToolsOpen ? FOCUS_TOOLS : FOCUS_CENTER; } void Play::focus(Vec2 screenSize) { camera.focus(body.actor.bounds_get(), screenSize, focus_get()); } 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); } } // 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 : CHEAT_KEYS) { 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("Cheats unlocked!"); audio->play(character->sounds[SOUND_CHEATS]); } if (cheatIndex > 0 && ImGui::GetTime() - cheatTime > CHEAT_TIME) cheatIndex = 0; } // Answers what the body did this tick: dialogue for digesting and stage ups, and the ending at the final stage. void Play::events_handle(Vec2 screenSize) { Dialogue& dialogue = character->dialogue; for (Event event : events) { if (event == EVENT_DIGESTED && !text.isScene && text.is_interruptible()) text.set(*this, dialogue.hook_get(HOOK_DIGEST)); if (event == EVENT_STAGE_UP && !text.isScene) text.set(*this, dialogue.hook_get(HOOK_STAGE_UP)); if (event != EVENT_STAGE_FINAL || isPostgame || isPostgamePending) continue; isPostgame = isCheats = !dialogue.is_hook(HOOK_END); if (isPostgame) continue; text.set(*this, dialogue.hook_get(HOOK_END), TEXT_UNINTERRUPTIBLE); isMenuOpen = isToolsOpen = false; body.stats.calories = 0; body.stats.digestionProgress = 0; items.drop(); isPostgamePending = true; focus(screenSize); } events.clear(); } void Play::update(Vec2 screenSize) { ImGuiIO& io = ImGui::GetIO(); if (std::exchange(camera.isFocusPending, false)) focus(screenSize); body.stats.playtime += TICK; cheats_update(); text.update(*this); if (isPostgamePending && (!text.entry || (text.entry->isTerminal && text.isFinished)) && !text.isScene) { isMenuOpen = isPostgame = isCheats = true; isPostgamePending = false; focus(screenSize); } if (autofeed.isEnabled) cursorType = autofeed.type_get(*this); Pointer pointer; pointer.position = camera.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; autofeed.isActive = false; if (text.isScene) itemPointer = interactPointer = {.position = pointer.position, .isCaptured = true}; else autofeed.update(*this, itemPointer, interactPointer); items.update(*this, itemPointer); interactions.update(*this, interactPointer); if (cursor.anm2 && !autofeed.isActive && pointer.isClicked && !items.isHovered && !interactions.isHovering && cursorState == CURSOR_DEFAULT) cursor.play(character->cursor.click, TRANSITION_FORCE, INTERRUPT_ALWAYS); body.update(events); events_handle(screenSize); area_set(); 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) == autofeed.isActive; if ((!isActive || cursorState == CURSOR_DEFAULT) && !each->pending) each->play(character->cursor.idle, TRANSITION_IF_IDLE); each->update(*audio); } cursorState = CURSOR_DEFAULT; cursor.position = {io.MousePos.x, io.MousePos.y}; camera.update(*this, screenSize); for (int i = 0; i < (int)toasts.size(); i++) if (--toasts[i].life <= 0) toasts.erase(toasts.begin() + i--); if ((autosaveTime += TICK) > AUTOSAVE_TIME) { save(); autosaveTime = 0; } } void Play::render(Renderer& renderer, Vec2 screenSize) { Affine view = camera.view_get(screenSize); area.render(renderer, view); body.actor.render(renderer, view); if (!text.isScene) for (ItemEntity& entity : items.list) entity.actor.render(renderer, view); if (autofeed.isActive) autoCursor.render(renderer, view); for (Actor& particle : particles) particle.render(renderer, view); } Vec2 Camera::world_get(Vec2 screen) { return pan + screen / (zoom / PERCENT); } Affine Camera::view_get(Vec2 screenSize) { float scale = zoom / PERCENT; return {scale, 0, 0, scale, -pan.x * scale + shake.x * screenSize.x, -pan.y * scale + shake.y * screenSize.y}; } void Camera::shake_add(float magnitude, int time) { shakeTimeMax = std::max(1, time); shakeTime = shakeTimeMax; shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)}; } // Zooms and pans so a target fills the view, leaving room for the open panels. void Camera::focus(Rect target, Vec2 screenSize, Focus panels) { if (!is_rect_valid(target)) return; target = rect_center(target, FOCUS_PADDING); float factor = std::min(screenSize.x / target.w, screenSize.y / target.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(target) - screenSize / factor / 2; 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; } // Pans with the middle mouse (or control and the left), zooms through the ladder with the wheel toward the mouse, and // settles the screen shake. void Camera::update(Play& play, Vec2 screenSize) { ImGuiIO& io = ImGui::GetIO(); Cursor& cursor = play.character->cursor; if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) { if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || (ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl))) { play.cursor.play(cursor.pan); 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); play.cursor.play(cursor.zoom); 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)) play.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 = {}; } }