diff --git a/MODDING.md b/MODDING.md index 47be0d9..e00e6b8 100644 --- a/MODDING.md +++ b/MODDING.md @@ -150,6 +150,8 @@ The areas which will expand based on how full the character is and their progres | `null` | The null that will expand. | | `scaleMax` | The scale (in percent) that will be added at maximum. Know that a scale of 100 is the normal scale of an animation. | +The first expand area is treated as the belly: it breathes, jiggles when smacked, rubbed or fed, and is where digestion and stage-up effects come from. + ### interactTypes, interactAreas `interactTypes` is an object of interaction types by name, giving each type's defaults. Rub, Smack, Kiss and Turn Around come from common. A type gets a button on the taskbar only if one of the character's areas uses it. `interactAreas` is a list of the concrete areas on a character which can be interacted with; usually for belly rubs, kisses, etc. Each area names its `type` and takes all of that type's settings, changing whichever it gives itself. @@ -199,7 +201,7 @@ The UI's sounds. | `sounds` | `open` and `close` (the taskbar or one of its windows), `hover` and `select` (a widget), and `cheats` (after entering a special code to activate cheats). | ### particles -An object of particles by name, each with the `anm2` and `animation` it plays once where spawned. +An object of particles by name, each with the `anm2` and `animation` it plays once where spawned. The `Puff` particle is also used for the dust when an item lands hard. # items.json diff --git a/src/audio.cpp b/src/audio.cpp index f8b5d75..60a8faf 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -2,6 +2,8 @@ #include #include +constexpr float PITCH_VARIATION = 0.06f; + void Audio::init() { if (SDL_InitSubSystem(SDL_INIT_AUDIO)) @@ -88,6 +90,7 @@ void Audio::play(Sound* sound) SDL_AudioSpec spec = {SDL_AUDIO_S16, sound->channels, sound->rate}; SDL_ClearAudioStream(voices[voice]); SDL_SetAudioStreamFormat(voices[voice], &spec, nullptr); + SDL_SetAudioStreamFrequencyRatio(voices[voice], random_get(1 - PITCH_VARIATION, 1 + PITCH_VARIATION)); SDL_PutAudioStreamData(voices[voice], sound->samples, sound->frames * sound->channels * (int)sizeof(short)); } diff --git a/src/body.cpp b/src/body.cpp index d98bbe8..f9a145f 100644 --- a/src/body.cpp +++ b/src/body.cpp @@ -1,5 +1,9 @@ #include "play.h" +constexpr float BREATH_AMPLITUDE = 1.5f; +constexpr float BREATH_SPEED = 2.2f; +constexpr float JIGGLE_DAMPING = 0.12f; +constexpr float JIGGLE_STIFFNESS = 0.18f; constexpr float SWAP_STEP = 0.5f; enum SwapMode @@ -58,6 +62,20 @@ float Body::capacity_percent_get() return capacityMax > 0 ? stats.calories / capacityMax : 0; } +// Pushes the belly's spring, harder the bigger the body has grown. +void Body::jiggle_add(float amount) +{ + jiggleVelocity += amount * (1 + (float)stage / std::max(1, (int)character->stages.size())); +} + +// Where the belly is (its expand area's null), or else the middle of the body. +Vec2 Body::belly_get() +{ + std::vector& areas = character->expandAreas; + Rect rect = areas.empty() ? Rect{} : actor.null_rect_get(areas[0].null); + return rect_middle(is_rect_valid(rect) ? rect : actor.bounds_get()); +} + bool Body::is_over_capacity() { return stats.calories > stats.capacity; @@ -184,7 +202,10 @@ void Body::update(std::vector& events) if (!isTalking) idle_play(); } - actor.update(*audio); + if (freeze > 0) + freeze--; + else + actor.update(*audio); if (stats.isDigesting && --stats.digestionTimer <= 0) { @@ -205,9 +226,12 @@ void Body::update(std::vector& events) stage_set(nextStage); isStageUp = true; actor.isLocked = true; + events.push_back(EVENT_STAGE_START); } else events.push_back(EVENT_DIGESTED); + gained = nextWeight - stats.weight; + events.push_back(EVENT_GAINED); stats.weight = nextWeight; stats.isDigesting = false; stats.digestionTimer = traits.digestionTime; @@ -222,6 +246,7 @@ void Body::update(std::vector& events) stats.isDigesting = true; stats.digestionTimer = traits.digestionTime; audio->play(character->sounds[SOUND_DIGEST]); + events.push_back(EVENT_DIGEST_START); } } values_clamp(); @@ -234,13 +259,21 @@ void Body::update(std::vector& events) float capacityProgress = capacity_percent_get(); if (stats.isDigesting) capacityProgress *= (float)stats.digestionTimer / std::max(1, traits.digestionTime); - for (ExpandArea& expandArea : character->expandAreas) + jiggleVelocity += -jiggle * JIGGLE_STIFFNESS - jiggleVelocity * JIGGLE_DAMPING; + jiggle += jiggleVelocity; + float growth = 1 + (float)stage / std::max(1, (int)character->stages.size()); + float breath = std::sin(stats.playtime * BREATH_SPEED) * BREATH_AMPLITUDE * growth; + for (int i = 0; i < (int)character->expandAreas.size(); i++) { + ExpandArea& expandArea = character->expandAreas[i]; float add = std::clamp((stageProgress + capacityProgress) * expandArea.scaleMax / 2, 0.0f, expandArea.scaleMax); + Vec2 scale = {add, add}; + if (i == 0) + scale = scale + Vec2{jiggle, breath - jiggle}; if (expandArea.layer >= 0) - actor.layers[expandArea.layer].scale = {add, add}; + actor.layers[expandArea.layer].scale = scale; if (expandArea.null >= 0) - actor.nullScales[expandArea.null] = {add, add}; + actor.nullScales[expandArea.null] = scale; } isTalking = is_swapping(actor, character->talk, talkTime, isTalking, SWAP_LOOP); diff --git a/src/effects.cpp b/src/effects.cpp new file mode 100644 index 0000000..e7651d6 --- /dev/null +++ b/src/effects.cpp @@ -0,0 +1,50 @@ +#include "effects.h" + +constexpr float FLASH_FADE = 0.04f; +constexpr float FLOATER_RISE = 0.8f; +constexpr float SPARK_DRAG = 0.95f; +constexpr float SPEED_MIN = 0.4f; + +void Effects::update() +{ + for (int i = 0; i < (int)floaters.size(); i++) + { + floaters[i].position.y -= FLOATER_RISE; + if (--floaters[i].life <= 0) + floaters.erase(floaters.begin() + i--); + } + for (int i = 0; i < (int)sparks.size(); i++) + { + Spark& spark = sparks[i]; + spark.velocity = spark.velocity * SPARK_DRAG; + spark.velocity.y += spark.gravity; + spark.position = spark.position + spark.velocity; + if (--spark.life <= 0) + sparks.erase(sparks.begin() + i--); + } + flash = std::max(0.0f, flash - FLASH_FADE); +} + +void Effects::floater_add(Vec2 position, float value, FloaterType type) +{ + floaters.push_back({position, value, FLOATER_LIFE, type}); +} + +void Effects::spark_add(Spark spark) +{ + spark.lifeMax = spark.life; + sparks.push_back(spark); +} + +// Throws count copies of a spark out in every direction, at up to speed. +void Effects::burst_add(Spark spark, int count, float speed) +{ + for (int i = 0; i < count; i++) + { + float angle = random_get(0, 2 * PI); + float magnitude = random_get(speed * SPEED_MIN, speed); + Spark each = spark; + each.velocity = Vec2{std::cos(angle), std::sin(angle)} * magnitude + spark.velocity; + spark_add(each); + } +} diff --git a/src/effects.h b/src/effects.h new file mode 100644 index 0000000..741adf7 --- /dev/null +++ b/src/effects.h @@ -0,0 +1,54 @@ +#pragma once + +#include "common.h" +#include + +constexpr int FLOATER_LIFE = 75; + +enum FloaterType +{ + FLOATER_CALORIES, + FLOATER_WEIGHT +}; + +enum SparkShape +{ + SPARK_CIRCLE, + SPARK_STAR +}; + +// A number rising from where something happened, fading as it goes. +struct Floater +{ + Vec2 position; + float value = 0; + int life = 0; + FloaterType type = FLOATER_CALORIES; +}; + +// A glint or puff that drifts, falls (or rises) by its gravity, and fades over its life. +struct Spark +{ + Color color; + Vec2 position; + Vec2 velocity; + float gravity = 0; + float size = 0; + int life = 0; + int lifeMax = 0; + SparkShape shape = SPARK_CIRCLE; +}; + +// Short flourishes over the world: rising numbers, sparks, and the screen flashing. +class Effects +{ +public: + std::vector floaters; + std::vector sparks; + float flash = 0; + + void update(); + void floater_add(Vec2 position, float value, FloaterType type); + void spark_add(Spark spark); + void burst_add(Spark spark, int count, float speed); +}; diff --git a/src/interact.cpp b/src/interact.cpp index 2cca2d6..b67209f 100644 --- a/src/interact.cpp +++ b/src/interact.cpp @@ -1,6 +1,9 @@ #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; @@ -63,6 +66,11 @@ void Interactions::update(Play& play, Pointer pointer) 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; @@ -82,8 +90,10 @@ void Interactions::update(Play& play, Pointer pointer) play.cursorState = CURSOR_ACTION; active.play(entry.cursorActive); Vec2 moved = pointer.position - pointerPrevious; - if (entry.digestionHover > 0 && body.stats.calories > 0 && !body.stats.isDigesting && - (moved.x != 0 || moved.y != 0)) + 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; } } diff --git a/src/items.cpp b/src/items.cpp index b097aa1..78cd00d 100644 --- a/src/items.cpp +++ b/src/items.cpp @@ -8,7 +8,26 @@ constexpr float CRUMB_SPEED_X = 4; constexpr float CRUMB_SPEED_Y_MAX = 7; constexpr float CRUMB_SPEED_Y_MIN = 2; constexpr float CRUMB_SPIN = 15; +constexpr Color GOLD = {1, 0.85f, 0.3f, 1}; +constexpr Color TRAIL_COLOR = {1, 1, 1, 0.35f}; +constexpr const char* DUST_PARTICLE = "Puff"; +constexpr float BITE_JIGGLE = 4; +constexpr float DUST_IMPACT = 12; +constexpr float FANFARE_MIN = 0.75f; +constexpr float FANFARE_SHAKE = 0.015f; +constexpr float FANFARE_SPEED = 5; constexpr float HOLD_SPIN = 0.1f; +constexpr float RARE_SPARKLE_CHANCE = 12; +constexpr float RARE_SPARKLE_RISE = -0.5f; +constexpr float SQUASH_DECAY = 0.82f; +constexpr float SQUASH_MAX = 0.35f; +constexpr float SQUASH_PER_SPEED = 0.025f; +constexpr float TRAIL_SPEED = 12; +constexpr Spark FANFARE_SPARK = {GOLD, {}, {}, 0.05f, 16, 50, 0, SPARK_STAR}; +constexpr Spark RARE_SPARK = {GOLD, {}, {}, 0, 10, 40, 0, SPARK_STAR}; +constexpr Spark TRAIL_SPARK = {TRAIL_COLOR, {}, {}, 0, 7, 14, 0, SPARK_CIRCLE}; +constexpr int FANFARE_COUNT = 20; +constexpr int FANFARE_SHAKE_TIME = 12; constexpr int CRUMB_COUNT = 6; constexpr float ROTATION_GROUND_DAMPING = 0.85f; constexpr float ROTATION_MAX = 90; @@ -17,7 +36,7 @@ constexpr float THROW_THRESHOLD = 10; void Items::add(Play& play, int id, Vec2 position, Vec2 velocity, float rotation) { - ItemEntity& entity = list.emplace_back(ItemEntity{play.character->item_actor_get(id), velocity, 0, id}); + ItemEntity& entity = list.emplace_back(ItemEntity{play.character->item_actor_get(id), {}, velocity, 0, id}); entity.actor.position = position; entity.actor.rotation = rotation; discovered[id] = true; @@ -46,8 +65,17 @@ void Items::crumbs_spawn(ItemEntity& source) } } -// Moves an item or crumb a tick under gravity, bouncing off the bounds and settling on the ground. True if it bounced. -static bool motion_update(Play& play, ItemEntity& entity) +// How special an item's rarity is, from 0 (the first, or none) to 1 (the last). +static float rarity_intensity_get(Play& play, int id) +{ + int rarity = play.character->catalog.items[id].rarity; + int count = (int)play.character->catalog.rarities.size(); + return rarity > 0 && count > 1 ? (float)rarity / (count - 1) : 0; +} + +// Moves an item or crumb a tick under gravity, bouncing off the bounds and settling on the ground. Gives the speed it +// hit something at (0 if it didn't), and squashes it by that. +static float motion_update(Play& play, ItemEntity& entity) { Area& area = play.character->area; Rect bounds = play.bounds; @@ -58,16 +86,17 @@ static bool motion_update(Play& play, ItemEntity& entity) gravity = gravity != 0 ? gravity : area.gravity; float right = bounds.x + bounds.w; float bottom = bounds.y + bounds.h; - bool isBounced = false; + float impact = 0; position = position + velocity; rotation += entity.angularVelocity; if (position.x <= bounds.x || position.x >= right) { position.x = position.x <= bounds.x ? bounds.x + 1 : right - 1; + impact = std::abs(velocity.x); + entity.squash = Vec2{-1, 1} * std::min(SQUASH_MAX, impact * SQUASH_PER_SPEED); velocity.x = -velocity.x * area.friction; entity.angularVelocity = -entity.angularVelocity * area.friction; rotation = -rotation; - isBounced = true; } if (position.y <= bounds.y || position.y >= bottom) { @@ -82,16 +111,17 @@ static bool motion_update(Play& play, ItemEntity& entity) } else { + impact = std::abs(velocity.y); + entity.squash = Vec2{1, -1} * std::min(SQUASH_MAX, impact * SQUASH_PER_SPEED); velocity.y = -velocity.y; entity.angularVelocity *= area.friction; - isBounced = true; } 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)}; - return isBounced; + return impact; } // Forgets which item is being eaten, and lets go of the held one unless asked to keep it. @@ -209,6 +239,13 @@ void Items::update(Play& play, Pointer pointer) audio.play(character.sounds[SOUND_NEW_ITEM]); } add(play, id, position); + if (rarity_intensity_get(play, id) >= FANFARE_MIN) + { + Spark spark = FANFARE_SPARK; + spark.position = position; + play.effects.burst_add(spark, FANFARE_COUNT, FANFARE_SPEED); + play.camera.shake_add(FANFARE_SHAKE, FANFARE_SHAKE_TIME); + } stats.totalSpawned++; play.particles_spawn(character.catalog.summonParticles, position); } @@ -256,6 +293,8 @@ void Items::update(Play& play, Pointer pointer) traits.digestionRateMin, traits.digestionRateMax); stats.totalConsumed++; crumbs_spawn(entity); + play.effects.floater_add(entity.actor.position, item.calories, FLOATER_CALORIES); + body.jiggle_add(BITE_JIGGLE); removed = held; drop(); feeding.animation = body.actor.animation; @@ -297,6 +336,7 @@ void Items::update(Play& play, Pointer pointer) if (isThrown) text_play(HOOK_THROW); entity.velocity = entity.velocity - delta; + entity.isThrown = isThrown; release(isThrown); } if (removed >= 0) @@ -347,8 +387,32 @@ void Items::update(Play& play, Pointer pointer) position = {std::clamp(position.x, bounds.x, bounds.x + bounds.w), std::clamp(position.y, bounds.y, bounds.y + bounds.h)}; } - else if (motion_update(play, entity)) - audio.play(character.sounds[SOUND_BOUNCE]); + else + { + float impact = motion_update(play, entity); + if (impact > 0) + audio.play(character.sounds[SOUND_BOUNCE]); + if (impact >= DUST_IMPACT) + play.particles_spawn({DUST_PARTICLE}, position); + entity.isThrown &= vec2_length(entity.velocity) >= TRAIL_SPEED; + if (entity.isThrown) + { + Spark spark = TRAIL_SPARK; + spark.position = position; + play.effects.spark_add(spark); + } + } + entity.squash = entity.squash * SQUASH_DECAY; + for (LayerChange& layer : entity.actor.layers) + layer.scale = entity.squash * PERCENT; + if (i != held && is_chance(rarity_intensity_get(play, entity.id) * RARE_SPARKLE_CHANCE)) + { + Rect rect = entity.actor.bounds_get(); + Spark spark = RARE_SPARK; + spark.position = {rect.x + random_get(0, rect.w), rect.y + random_get(0, rect.h)}; + spark.velocity = {0, RARE_SPARKLE_RISE}; + play.effects.spark_add(spark); + } } for (int i = 0; i < (int)crumbs.size(); i++) diff --git a/src/play.cpp b/src/play.cpp index be22cec..5134a95 100644 --- a/src/play.cpp +++ b/src/play.cpp @@ -11,7 +11,24 @@ constexpr ImGuiKey CHEAT_KEYS[] = {ImGuiKey_UpArrow, ImGuiKey_DownArrow, ImGu ImGuiKey_RightArrow, ImGuiKey_B, ImGuiKey_A}; constexpr const char* KEY_DISCOVERED = "discovered"; constexpr const char* KEY_ITEM = "item"; +constexpr Spark DIGEST_SPARK = {{1, 1, 1, 0.5f}, {}, {}, -0.06f, 10, 50, 0, SPARK_CIRCLE}; +constexpr Spark STAGE_SPARK = {{1, 0.85f, 0.3f, 1}, {}, {}, 0.06f, 18, 70, 0, SPARK_STAR}; constexpr float AUTOSAVE_TIME = 30; +constexpr float DIGEST_JIGGLE = 3; +constexpr float DIGEST_SHAKE = 0.006f; +constexpr float DIGEST_SPEED = 2; +constexpr float PUNCH_DECAY = 0.93f; +constexpr float PUNCH_EPSILON = 0.001f; +constexpr float STAGE_FLASH = 0.7f; +constexpr float STAGE_JIGGLE = 15; +constexpr float STAGE_PUNCH = 0.08f; +constexpr float STAGE_SHAKE = 0.04f; +constexpr float STAGE_SPEED = 8; +constexpr int DIGEST_COUNT = 10; +constexpr int DIGEST_SHAKE_TIME = 40; +constexpr int STAGE_COUNT = 28; +constexpr int STAGE_FREEZE = 5; +constexpr int STAGE_SHAKE_TIME = 30; constexpr float CHEAT_TIME = 5; constexpr float FOCUS_PADDING = 100; constexpr float SHAKE_DECAY = 0.97f; @@ -176,6 +193,25 @@ void Play::events_handle(Vec2 screenSize) Dialogue& dialogue = character->dialogue; for (Event event : events) { + Spark spark = event == EVENT_STAGE_START ? STAGE_SPARK : DIGEST_SPARK; + spark.position = body.belly_get(); + if (event == EVENT_GAINED && body.gained > 0) + effects.floater_add(spark.position, body.gained, FLOATER_WEIGHT); + if (event == EVENT_DIGEST_START) + { + effects.burst_add(spark, DIGEST_COUNT, DIGEST_SPEED); + camera.shake_add(DIGEST_SHAKE, DIGEST_SHAKE_TIME); + body.jiggle_add(DIGEST_JIGGLE); + } + if (event == EVENT_STAGE_START) + { + effects.burst_add(spark, STAGE_COUNT, STAGE_SPEED); + effects.flash = STAGE_FLASH; + camera.shake_add(STAGE_SHAKE, STAGE_SHAKE_TIME); + camera.punch = STAGE_PUNCH; + body.jiggle_add(STAGE_JIGGLE); + body.freeze = STAGE_FREEZE; + } if (event == EVENT_DIGESTED && !text.isScene && text.is_interruptible()) text.set(*this, dialogue.hook_get(HOOK_DIGEST)); if (event == EVENT_STAGE_UP && !text.isScene) @@ -240,6 +276,7 @@ void Play::update(Vec2 screenSize) area_set(); area.update(*audio); + effects.update(); for (int i = 0; i < (int)particles.size(); i++) { particles[i].update(*audio); @@ -290,10 +327,14 @@ Vec2 Camera::world_get(Vec2 screen) return pan + screen / (zoom / PERCENT); } +// The world's transform to the screen, zoomed further about the screen's middle by the punch. 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}; + Vec2 offset = Vec2{-pan.x * scale + shake.x * screenSize.x, -pan.y * scale + shake.y * screenSize.y} * (1 + punch) - + screenSize / 2 * punch; + scale *= 1 + punch; + return {scale, 0, 0, scale, offset.x, offset.y}; } void Camera::shake_add(float magnitude, int time) @@ -344,6 +385,9 @@ void Camera::update(Play& play, Vec2 screenSize) if (ImGui::IsKeyPressed(ImGuiKey_Home)) play.focus(screenSize); + punch = punch * PUNCH_DECAY; + if (punch < PUNCH_EPSILON) + punch = 0; if (shakeTime > 0) { shakeTime--; diff --git a/src/play.h b/src/play.h index d56ab80..6cab9b6 100644 --- a/src/play.h +++ b/src/play.h @@ -2,6 +2,7 @@ #include "character.h" #include "dialogue.h" +#include "effects.h" #include "save.h" constexpr float DIGESTION_MAX = 100; @@ -31,7 +32,10 @@ enum Hold // What happened to the body in a tick, answered once it's done. enum Event { + EVENT_DIGEST_START, EVENT_DIGESTED, + EVENT_GAINED, + EVENT_STAGE_START, EVENT_STAGE_UP, EVENT_STAGE_FINAL }; @@ -50,14 +54,17 @@ struct Pointer bool isRightClicked = false; }; -// An item in the world, or a crumb of one (which has a life, in ticks). +// An item in the world, or a crumb of one (which has a life, in ticks). Squash is the added scale of its last impact, +// and a thrown item trails until it slows down. struct ItemEntity { Actor actor; + Vec2 squash; Vec2 velocity; float angularVelocity = 0; int id = 0; int life = 0; + bool isThrown = false; }; struct Toast @@ -68,7 +75,8 @@ struct Toast class Play; -// The character's body: its stats, digestion and stages, and the animations they choose. +// The character's body: its stats, digestion and stages, and the animations they choose. The belly (the first expand +// area) breathes and jiggles on a spring, and freeze holds the body still for a hit's few ticks. class Body { public: @@ -79,7 +87,11 @@ public: Character* character = nullptr; Audio* audio = nullptr; float blinkTime = 0; + float gained = 0; + float jiggle = 0; + float jiggleVelocity = 0; float talkTime = 0; + int freeze = 0; int stage = 0; bool isBlinking = false; bool isStageUp = false; @@ -93,7 +105,9 @@ public: void idle_override_set(const std::string& animation); void stage_set(int index); void values_clamp(); + void jiggle_add(float amount); std::string idle_get(); + Vec2 belly_get(); Rect eat_rect_get(const EatArea& eatArea); float capacity_max_get(); float capacity_percent_get(); @@ -180,6 +194,7 @@ class Camera public: Vec2 pan; Vec2 shake; + float punch = 0; float zoom = ZOOM_LADDER[0]; int shakeTime = 0; int shakeTimeMax = 0; @@ -202,6 +217,7 @@ public: Autofeed autofeed; Body body; Camera camera; + Effects effects; Interactions interactions; Items items; Text text; diff --git a/src/ui.cpp b/src/ui.cpp index 3c2105d..20fa9fb 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -25,6 +25,14 @@ constexpr float INDICATOR_SIZE = 0.35f; constexpr float INDICATOR_SPEED = 2; constexpr float ITEM_SIZE = 96; constexpr float ROUNDING = 10; +constexpr ImVec4 CALORIES_COLOR = {1, 0.85f, 0.3f, 1}; +constexpr float BAR_EASE = 6; +constexpr float BAR_SHAKE = 2; +constexpr float BAR_SHAKE_SPEED = 40; +constexpr float FLOATER_OUTLINE = 2; +constexpr float OVERSTUFFED_PULSE = 0.35f; +constexpr float OVERSTUFFED_SPEED = 6; +constexpr float STAR_WAIST = 0.3f; constexpr float BAR_WIDTH_MIN = 120; constexpr float BUTTON_SIZE_MAX = 96; constexpr float BUTTON_SIZE_MIN = 56; @@ -558,9 +566,16 @@ void Ui::taskbar_draw(Play& play, ImVec2 screen) ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(labels[i]); ImGui::SameLine(); + float time = (float)ImGui::GetTime(); + barFills[i] += (fills[i] - barFills[i]) * std::min(1.0f, BAR_EASE * ImGui::GetIO().DeltaTime); if (i == 1 && overstuffed > 0) - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(1, 1 - overstuffed, 1 - overstuffed, 1)); - ImGui::ProgressBar(fills[i], {widths[i], 0}, bars[i].c_str()); + { + float red = 1 - overstuffed * (1 - OVERSTUFFED_PULSE * (1 + std::sin(time * OVERSTUFFED_SPEED)) / 2); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(1, red, red, 1)); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::SetCursorScreenPos({cursor.x + std::sin(time * BAR_SHAKE_SPEED) * BAR_SHAKE * overstuffed, cursor.y}); + } + ImGui::ProgressBar(barFills[i], {widths[i], 0}, bars[i].c_str()); if (i == 1 && overstuffed > 0) ImGui::PopStyleColor(); if (i == 0 && ImGui::BeginItemTooltip()) @@ -709,6 +724,54 @@ void Ui::text_draw(Play& play, ImVec2 screen) ImGui::PopFont(); } +// The flourishes over the world: sparks, rising numbers (outlined so they read on anything), and the screen's flash. +void Ui::effects_draw(Play& play, ImVec2 screen) +{ + Effects& effects = play.effects; + Affine view = play.camera.view_get({screen.x, screen.y}); + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); + for (Spark& spark : effects.sparks) + { + Vec2 at = view * spark.position; + float fade = (float)spark.life / std::max(1, spark.lifeMax); + float size = spark.size * view.a * fade; + ImU32 color = ImGui::GetColorU32(ImVec4(spark.color.r, spark.color.g, spark.color.b, spark.color.a * fade)); + if (spark.shape == SPARK_CIRCLE) + { + drawList->AddCircleFilled({at.x, at.y}, size, color); + continue; + } + float waist = size * STAR_WAIST; + drawList->AddQuadFilled({at.x, at.y - size}, {at.x + waist, at.y}, {at.x, at.y + size}, {at.x - waist, at.y}, + color); + drawList->AddQuadFilled({at.x - size, at.y}, {at.x, at.y - waist}, {at.x + size, at.y}, {at.x, at.y + waist}, + color); + } + + ImFont* font = ImGui::GetFont(); + for (Floater& floater : effects.floaters) + { + std::string text = floater.type == FLOATER_CALORIES ? text_format("+%.0f kcal", floater.value) + : "+" + weight_format(floater.value, *measurement); + float alpha = std::min(1.0f, (float)floater.life / (FLOATER_LIFE / 2)); + ImVec4 color = floater.type == FLOATER_CALORIES ? CALORIES_COLOR : ImGui::GetStyleColorVec4(ImGuiCol_PlotHistogram); + color.w = alpha; + ImVec2 size = font->CalcTextSizeA(FONT_HEADER_2, FLT_MAX, 0, text.c_str()); + Vec2 at = view * floater.position; + ImVec2 position = {at.x - size.x / 2, at.y - size.y / 2}; + ImU32 outline = ImGui::GetColorU32(ImVec4(0, 0, 0, alpha)); + for (int x = -1; x <= 1; x++) + for (int y = -1; y <= 1; y++) + if (x != 0 || y != 0) + drawList->AddText(font, FONT_HEADER_2, + {position.x + x * FLOATER_OUTLINE, position.y + y * FLOATER_OUTLINE}, outline, + text.c_str()); + drawList->AddText(font, FONT_HEADER_2, position, ImGui::GetColorU32(color), text.c_str()); + } + if (effects.flash > 0) + drawList->AddRectFilled({0, 0}, screen, ImGui::GetColorU32(ImVec4(1, 1, 1, effects.flash))); +} + // Fading messages stacked down from the top right, newest first. void Ui::toasts_draw(Play& play) { @@ -994,6 +1057,7 @@ void Ui::play_update(Play& play, Settings& settings) character = play.character; measurement = &settings.measurement; ImVec2 screen = ImGui::GetIO().DisplaySize; + effects_draw(play, screen); if (play.items.tooltip >= 0 && !play.isBlocked && ImGui::BeginTooltip()) { item_details_draw(*character, &character->catalog.items[play.items.tooltip]); diff --git a/src/ui.h b/src/ui.h index e0f26c5..a09f05b 100644 --- a/src/ui.h +++ b/src/ui.h @@ -70,6 +70,7 @@ public: Commands* commands = nullptr; Measurement* measurement = nullptr; Renderer* renderer = nullptr; + float barFills[3] = {}; float buttonSize = 0; float taskbarSlide = 1; int characterIndex = 0; @@ -100,4 +101,5 @@ private: void settings_draw(Settings& settings); void text_draw(Play& play, ImVec2 screen); void toasts_draw(Play& play); + void effects_draw(Play& play, ImVec2 screen); };