diff --git a/CHANGELOG.md b/CHANGELOG.md index 80186fa..f863caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-7](https://github.com/itk-dev/itk-project-database/pull/7) + Add a mascot motivating users to create and complete initiatives * [PR-6](https://github.com/itk-dev/itk-project-database/pull/6) Mark udfyldningsgrad fields with a star that flies into a progress trophy * [PR-5](https://github.com/itk-dev/itk-project-database/pull/5) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js new file mode 100644 index 0000000..ca7364f --- /dev/null +++ b/assets/controllers/mascot_controller.js @@ -0,0 +1,378 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * A friendly star mascot in the bottom-right corner. It greets the user, then + * periodically pops a speech bubble cheering them on to start or finish an + * initiative. Rarely it invites the user to a game of catch: click it and it + * darts away from the pointer; catch it and it turns the tables and chases the + * pointer until it tags it back. Messages arrive already translated. + */ +export default class extends Controller { + static targets = ["bubble", "text", "cta", "play", "finish"]; + + static values = { + messages: { type: Array, default: [] }, + interval: { type: Number, default: 50000 }, + playChance: { type: Number, default: 0.01 }, + invite: String, + caught: String, + gotcha: String, + giveup: String, + escaped: String, + finishTexts: { type: Array, default: [] }, + }; + + connect() { + this.mode = "idle"; + this.timers = {}; + const state = this.loadState(); + this.lastIndex = state.lastIndex ?? -1; + this.lastShownAt = state.lastShownAt ?? 0; + // Resume the cadence where the previous page left off, so navigating + // around doesn't pop a fresh message on every load. + const sinceLast = Date.now() - this.lastShownAt; + this.scheduleNext(Math.max(1800, this.intervalValue - sinceLast)); + } + + disconnect() { + this.endGame(); + this.clearNamedTimer("cycle"); + this.clearNamedTimer("show"); + window.clearTimeout(this.fadeTimer); + window.clearTimeout(this.inviteTimer); + window.clearTimeout(this.quietTimer); + } + + scheduleNext(delay) { + this.startTimer("cycle", () => this.tick(), delay); + } + + // Pausable timers: the cycle to the next message and the bubble's auto-hide. + // Hovering the mascot freezes whatever time is left; leaving resumes it, so a + // user reading or admiring the bubble is never rushed or interrupted. + startTimer(name, callback, delay) { + this.clearNamedTimer(name); + const timer = { callback, remaining: delay, startedAt: Date.now() }; + timer.id = window.setTimeout(() => { + delete this.timers[name]; + callback(); + }, delay); + this.timers[name] = timer; + } + + clearNamedTimer(name) { + const timer = this.timers[name]; + if (timer) { + window.clearTimeout(timer.id); + delete this.timers[name]; + } + } + + pause() { + const now = Date.now(); + for (const timer of Object.values(this.timers)) { + if (null === timer.id) { + continue; + } + window.clearTimeout(timer.id); + timer.id = null; + timer.remaining = Math.max( + 0, + timer.remaining - (now - timer.startedAt), + ); + } + } + + resume() { + for (const [name, timer] of Object.entries(this.timers)) { + if (null !== timer.id) { + continue; + } + timer.startedAt = Date.now(); + timer.id = window.setTimeout(() => { + delete this.timers[name]; + timer.callback(); + }, timer.remaining); + } + } + + tick() { + if ("idle" === this.mode && !this.quiet) { + if ( + !this.prefersReducedMotion && + Math.random() < this.playChanceValue + ) { + this.invite(); + } else { + this.speak(); + } + } + this.scheduleNext(this.intervalValue); + } + + // The avatar click means different things depending on the current mode. + poke() { + if ("invited" === this.mode) { + this.startFlee(); + } else if ("flee" === this.mode) { + this.caught(); + } else if ("idle" === this.mode) { + this.speak(); + } + } + + speak() { + // Now and then, nudge the user to finish their least-complete initiative, + // picking one of the finish lines at random for variety. + const finishTexts = this.finishTextsValue; + if ( + this.hasFinishTarget && + finishTexts.length > 0 && + Math.random() < 0.4 + ) { + this.say( + finishTexts[Math.floor(Math.random() * finishTexts.length)], + "finish", + ); + + return; + } + this.say(this.nextMessage(), "cta"); + } + + invite() { + this.mode = "invited"; + this.say(this.inviteValue, "play"); + window.clearTimeout(this.inviteTimer); + this.inviteTimer = window.setTimeout(() => { + if ("invited" === this.mode) { + this.mode = "idle"; + this.hide(); + } + }, 15000); + } + + startFlee() { + window.clearTimeout(this.inviteTimer); + this.hide(); + this.mode = "flee"; + this.anchorPosition(); + this.fleeHandler = (event) => this.onFlee(event); + document.addEventListener("pointermove", this.fleeHandler); + this.giveUpTimer = window.setTimeout(() => this.giveUp(), 15000); + } + + onFlee(event) { + if (this.fleeCooldown) { + return; + } + const box = this.avatar.getBoundingClientRect(); + const cx = box.left + box.width / 2; + const cy = box.top + box.height / 2; + if (Math.hypot(cx - event.clientX, cy - event.clientY) >= 75) { + return; + } + // A short, bounded hop straight away from the pointer (clamped on-screen), + // so the mascot can be cornered and caught instead of teleporting away. + const angle = Math.atan2(cy - event.clientY, cx - event.clientX); + const here = this.element.getBoundingClientRect(); + this.moveTo( + here.left + Math.cos(angle) * 95, + here.top + Math.sin(angle) * 95, + ); + this.fleeCooldown = window.setTimeout(() => { + this.fleeCooldown = null; + }, 220); + } + + caught() { + window.clearTimeout(this.giveUpTimer); + document.removeEventListener("pointermove", this.fleeHandler); + this.mode = "chase-prep"; + this.say(this.caughtValue); + this.prepTimer = window.setTimeout(() => this.startChase(), 1700); + } + + startChase() { + this.hide(); + this.mode = "chase"; + this.element.classList.add("mascot--chasing"); + this.pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + this.chaseHandler = (event) => { + this.pointer.x = event.clientX; + this.pointer.y = event.clientY; + }; + document.addEventListener("pointermove", this.chaseHandler); + this.giveUpTimer = window.setTimeout(() => this.escape(), 8000); + this.chaseStep(); + } + + chaseStep() { + if ("chase" !== this.mode) { + return; + } + const box = this.avatar.getBoundingClientRect(); + const dx = this.pointer.x - (box.left + box.width / 2); + const dy = this.pointer.y - (box.top + box.height / 2); + const dist = Math.hypot(dx, dy); + if (dist < 38) { + this.gotcha(); + + return; + } + // Move toward the pointer, capped at a gentle top speed so the chase is + // playful and evadable rather than instant. + const step = Math.min(7, dist * 0.12) / dist; + const here = this.element.getBoundingClientRect(); + this.moveTo(here.left + dx * step, here.top + dy * step); + this.raf = window.requestAnimationFrame(() => this.chaseStep()); + } + + gotcha() { + this.endGame(); + this.say(this.gotchaValue); + } + + giveUp() { + this.endGame(); + this.say(this.giveupValue); + } + + // The mascot ran out of time chasing — it owns the loss, no "draw". + escape() { + this.endGame(); + this.say(this.escapedValue); + } + + endGame() { + window.cancelAnimationFrame(this.raf); + window.clearTimeout(this.giveUpTimer); + window.clearTimeout(this.prepTimer); + window.clearTimeout(this.fleeCooldown); + this.fleeCooldown = null; + if (this.fleeHandler) { + document.removeEventListener("pointermove", this.fleeHandler); + } + if (this.chaseHandler) { + document.removeEventListener("pointermove", this.chaseHandler); + } + this.element.classList.remove("mascot--playing", "mascot--chasing"); + this.element.style.left = ""; + this.element.style.top = ""; + this.mode = "idle"; + + // Stay quiet after a game so the closing quip can be read and nothing new + // pops up right after: it shows for ~8s, then ~5s of calm before cheering. + this.quiet = true; + window.clearTimeout(this.quietTimer); + this.quietTimer = window.setTimeout(() => { + this.quiet = false; + }, 13000); + } + + anchorPosition() { + const box = this.element.getBoundingClientRect(); + this.element.classList.add("mascot--playing"); + this.moveTo(box.left, box.top); + } + + moveTo(left, top) { + const margin = 8; + const w = this.element.offsetWidth; + const h = this.element.offsetHeight; + this.element.style.left = `${Math.max(margin, Math.min(left, window.innerWidth - w - margin))}px`; + this.element.style.top = `${Math.max(margin, Math.min(top, window.innerHeight - h - margin))}px`; + } + + say(text, action = null) { + if (!this.hasTextTarget || !text) { + return; + } + this.textTarget.textContent = text; + if (this.hasCtaTarget) { + this.ctaTarget.hidden = "cta" !== action; + } + if (this.hasPlayTarget) { + this.playTarget.hidden = "play" !== action; + } + if (this.hasFinishTarget) { + this.finishTarget.hidden = "finish" !== action; + } + this.bubbleTarget.hidden = false; + window.requestAnimationFrame(() => { + this.bubbleTarget.classList.add("is-visible"); + }); + this.startTimer("show", () => this.hide(), 20000); + this.lastShownAt = Date.now(); + this.saveState(); + } + + hide() { + this.bubbleTarget.classList.remove("is-visible"); + this.clearNamedTimer("show"); + window.clearTimeout(this.fadeTimer); + this.fadeTimer = window.setTimeout(() => { + this.bubbleTarget.hidden = true; + }, 250); + } + + // The × just closes the current bubble; the mascot stays and cheers again later. + close() { + if ("invited" === this.mode) { + this.mode = "idle"; + window.clearTimeout(this.inviteTimer); + } + this.hide(); + } + + // Pick a message different from the last one shown, so it never repeats twice. + nextMessage() { + const messages = this.messagesValue; + if (0 === messages.length) { + return ""; + } + if (1 === messages.length) { + return messages[0]; + } + + let index = this.lastIndex; + while (index === this.lastIndex) { + index = Math.floor(Math.random() * messages.length); + } + this.lastIndex = index; + + return messages[index]; + } + + // Persist the cadence across page loads so navigating doesn't re-trigger a + // greeting, and the last message isn't repeated on the next page. + loadState() { + try { + return JSON.parse(sessionStorage.getItem("mascot-state") || "{}"); + } catch { + return {}; + } + } + + saveState() { + try { + sessionStorage.setItem( + "mascot-state", + JSON.stringify({ + lastShownAt: this.lastShownAt ?? 0, + lastIndex: this.lastIndex, + }), + ); + } catch { + // sessionStorage may be unavailable (private mode / quota); ignore. + } + } + + get avatar() { + return this.element.querySelector(".mascot__avatar"); + } + + get prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 466ef33..0cbbf23 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1680,6 +1680,187 @@ textarea { } } +.mascot { + position: fixed; + right: var(--itk-space-5); + bottom: var(--itk-space-5); + z-index: 200; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--itk-space-2); + pointer-events: none; +} + +.mascot__avatar, +.mascot__bubble { + pointer-events: auto; +} + +.mascot__avatar { + width: 68px; + height: 68px; + padding: 11px; + border: none; + border-radius: var(--itk-radius-pill); + background-color: var(--itk-paper); + box-shadow: var(--itk-shadow-3); + cursor: pointer; + display: grid; + place-items: center; + animation: mascot-bob 3.4s ease-in-out infinite; + transition: transform 0.15s; +} + +.mascot__avatar:hover { + transform: scale(1.08); +} + +.mascot__avatar svg { + width: 100%; + height: 100%; + overflow: visible; +} + +@keyframes mascot-bob { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + +.mascot__bubble { + position: relative; + max-width: 260px; + padding: var(--itk-space-3) var(--itk-space-4); + background-color: var(--itk-paper); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-2); + font-size: var(--itk-text-sm); + color: var(--itk-ink); + opacity: 0; + transform: translateY(8px) scale(0.96); + transform-origin: bottom right; + transition: + opacity 0.25s ease, + transform 0.25s ease; +} + +.mascot__bubble.is-visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +.mascot__bubble::after { + content: ""; + position: absolute; + right: 20px; + bottom: -7px; + width: 13px; + height: 13px; + background-color: var(--itk-paper); + border-right: 1px solid var(--itk-slate-200); + border-bottom: 1px solid var(--itk-slate-200); + transform: rotate(45deg); +} + +.mascot__text { + margin: 0; + padding-right: var(--itk-space-3); +} + +.mascot__cta { + display: inline-block; + margin-top: var(--itk-space-2); + font-size: var(--itk-text-xs); + font-weight: 600; +} + +.mascot__cta[hidden] { + display: none; +} + +.mascot__play { + display: inline-block; + margin-top: var(--itk-space-2); + padding: 0; + border: none; + background: none; + color: var(--itk-primary); + font: inherit; + font-size: var(--itk-text-xs); + font-weight: 600; + cursor: pointer; +} + +.mascot__play:hover { + text-decoration: underline; +} + +.mascot__play[hidden] { + display: none; +} + +.mascot__close { + position: absolute; + top: 2px; + right: 6px; + border: none; + background: none; + color: var(--itk-slate-500); + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.mascot__close:hover { + color: var(--itk-ink); +} + +.mascot--playing { + right: auto; + bottom: auto; + transition: + left 0.18s ease, + top 0.18s ease; +} + +.mascot--playing .mascot__avatar { + animation: none; +} + +.mascot--playing .mascot__bubble { + position: absolute; + right: 0; + bottom: calc(100% + var(--itk-space-2)); + width: max-content; + max-width: 280px; +} + +.mascot--chasing { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .mascot__avatar { + animation: none; + } + + .mascot__bubble { + transition: opacity 0.15s ease; + transform: none; + } + + .mascot__bubble.is-visible { + transform: none; + } +} + .ts-wrapper .ts-control { display: flex; flex-wrap: wrap; diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index e80da40..2015c19 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -16,6 +16,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -166,6 +167,44 @@ public function countAll(): int ->getSingleScalarResult(); } + public function countByCreator(UserInterface $user): int + { + return (int) $this->createQueryBuilder('i') + ->select('COUNT(i.id)') + ->andWhere('i.createdBy = :user') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * The creator's least-complete initiative that isn't fully filled in yet, or + * null if none are outstanding. Completion is computed in PHP (not a stored + * column), so this scans only the creator's 50 most recent initiatives. + */ + public function findUnfinishedByCreator(UserInterface $user): ?Initiative + { + $initiatives = $this->createQueryBuilder('i') + ->andWhere('i.createdBy = :user') + ->setParameter('user', $user) + ->orderBy('i.createdAt', 'DESC') + ->setMaxResults(50) + ->getQuery() + ->getResult(); + + $unfinished = array_filter( + $initiatives, + static fn (Initiative $initiative): bool => $initiative->getCompletionPercentage() < 100, + ); + + usort( + $unfinished, + static fn (Initiative $a, Initiative $b): int => $a->getCompletionPercentage() <=> $b->getCompletionPercentage(), + ); + + return $unfinished[0] ?? null; + } + /** * @return array count keyed by status value (skips initiatives without a status) */ diff --git a/src/Twig/MascotExtension.php b/src/Twig/MascotExtension.php new file mode 100644 index 0000000..8037634 --- /dev/null +++ b/src/Twig/MascotExtension.php @@ -0,0 +1,49 @@ +context(...)), + ]; + } + + /** + * @return array{count: int, unfinished: Initiative|null} + */ + public function context(): array + { + $user = $this->security->getUser(); + if (!$user instanceof User) { + return ['count' => 0, 'unfinished' => null]; + } + + return [ + 'count' => $this->initiatives->countByCreator($user), + 'unfinished' => $this->initiatives->findUnfinishedByCreator($user), + ]; + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index ba236b5..97de542 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -56,6 +56,73 @@ {% block body %}{% endblock %} + {% block mascot %} + {% if app.user %} + {% set ctx = mascot_context() %} + {% set messages = [ + 'mascot.msg.idea'|trans, + 'mascot.msg.roll'|trans, + 'mascot.msg.bold'|trans, + 'mascot.msg.creativity'|trans, + 'mascot.msg.another'|trans, + 'mascot.msg.draft'|trans, + 'mascot.msg.future'|trans, + 'mascot.msg.spark'|trans, + 'mascot.msg.dream'|trans, + 'mascot.msg.coffee'|trans, + 'mascot.msg.small'|trans, + 'mascot.msg.colleague'|trans, + 'mascot.msg.today'|trans, + 'mascot.msg.create'|trans, + 'mascot.msg.thought'|trans, + 'mascot.msg.momentum'|trans, + 'mascot.msg.curious'|trans, + ] %} + {% if ctx.count > 0 %} + {% set messages = messages|merge(['mascot.praise'|trans({'%count%': ctx.count})]) %} + {% endif %} + {% set finishMessages = [] %} + {% if ctx.unfinished %} + {% set finishParams = {'%title%': ctx.unfinished.title, '%percent%': ctx.unfinished.completionPercentage} %} + {% set finishMessages = [ + 'mascot.finish.almost'|trans(finishParams), + 'mascot.finish.touches'|trans(finishParams), + 'mascot.finish.close'|trans(finishParams), + 'mascot.finish.blanks'|trans(finishParams), + 'mascot.finish.ending'|trans(finishParams), + 'mascot.finish.strong'|trans(finishParams), + ] %} + {% endif %} +
+ + +
+ {% endif %} + {% endblock %} + {% block flashes %} {% set all_flashes = app.flashes %} {% if all_flashes|length > 0 %} diff --git a/tests/Twig/MascotExtensionTest.php b/tests/Twig/MascotExtensionTest.php new file mode 100644 index 0000000..91fe500 --- /dev/null +++ b/tests/Twig/MascotExtensionTest.php @@ -0,0 +1,31 @@ +get(MascotExtension::class); + \assert($extension instanceof MascotExtension); + + // No user is logged in, so the mascot has no personal numbers to show. + self::assertSame(['count' => 0, 'unfinished' => null], $extension->context()); + } + + public function testRegistersTheMascotContextFunction(): void + { + self::bootKernel(); + $extension = static::getContainer()->get(MascotExtension::class); + \assert($extension instanceof MascotExtension); + + $names = array_map(static fn (\Twig\TwigFunction $fn): string => $fn->getName(), $extension->getFunctions()); + self::assertContains('mascot_context', $names); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 7b8bfe1..8afe425 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -1,6 +1,45 @@ app: name: Projektdatabase +mascot: + label: Din kreative makker + close: Luk + cta: Opret nyt initiativ + praise: "{1}Du har skabt ét initiativ — godt begyndt! 🌟|]1,Inf[Du har skabt %count% initiativer — flot arbejde! 🌟" + finish: + almost: "“%title%” er %percent% % færdig — skal vi gøre den helt færdig?" + touches: "“%title%” er %percent% % færdig og mangler kun de sidste detaljer. ✏️" + close: "Du er %percent% % gennem “%title%” — skal vi gøre den færdig?" + blanks: "“%title%” er %percent% % udfyldt — der mangler stadig et par felter. 📝" + ending: "“%title%” er %percent% % færdig. Giv den en værdig afslutning! 🏁" + strong: "“%title%” er %percent% % færdig — lad os tage den sidste bid! 💪" + finish_cta: Gør færdig + msg: + idea: "Har du en ny idé? Så er det her, den hører til. ✨" + roll: "Du er godt i gang — hvad bliver det næste?" + bold: "Ethvert godt initiativ starter med et modigt første skridt." + creativity: "Din kreativitet gør hele forskellen. 🌟" + another: "Endnu et initiativ? Ja tak!" + draft: "Lad ikke kladden samle støv — få den i mål." + future: "Se lige dig — du er med til at forme fremtiden." + spark: "Enhver stor forandring kræver en første gnist — har du en? ⚡" + dream: "Drøm stort — formularen skal nok følge med. 💭" + coffee: "En kop kaffe og fem minutter — mere skal en god idé ikke bruge. ☕" + small: "Ingen idé er for lille til at skrive ned." + colleague: "Et eller andet sted sidder en kollega og mangler præcis din idé. 🤝" + today: "I dag er en god dag at starte noget nyt." + create: "Vær den, der trykker på opret-knappen. 🚀" + thought: "Den tanke, du hele tiden vender tilbage til? Skriv den ned her. 📝" + momentum: "Én idé i dag kan forme morgendagen." + curious: "Jeg er spændt på, hvad du finder på nu. 👀" + play: + invite: "Pssst… har du tid til en omgang tagfat? 🌟" + start: "Leg tagfat" + caught: "Haha, du fangede mig! Nu er det min tur 😈" + gotcha: "Fanget! Det var sjovt. Nå, tilbage til arbejdet. 🎉" + giveup: "Pyha… lad os kalde det uafgjort. 😅" + escaped: "Argh, du slap væk! Du er alt for hurtig. 🏳️" + autosave: saving: Gemmer… saved: Gemt diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 7781270..56484e9 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,6 +1,45 @@ app: name: Project database +mascot: + label: Your creative buddy + close: Close + cta: Create a new initiative + praise: "{1}You've created one initiative — nice start! 🌟|]1,Inf[You've created %count% initiatives — great work! 🌟" + finish: + almost: "“%title%” is %percent%% done — want to finish it off?" + touches: "“%title%” is %percent%% there and just needs its final touches. ✏️" + close: "You’re %percent%% through “%title%” — shall we wrap it up?" + blanks: "“%title%” is %percent%% filled in — a few blanks to go. 📝" + ending: "“%title%” is %percent%% done. Give it the ending it deserves! 🏁" + strong: "“%title%” is %percent%% there — let’s finish strong! 💪" + finish_cta: Finish it + msg: + idea: "Got a fresh idea? Let’s give it a home. ✨" + roll: "You’re on a roll — what’s next?" + bold: "Every great initiative starts with one bold line." + creativity: "Your creativity makes all the difference. 🌟" + another: "Another initiative? Yes please!" + draft: "Don’t let that draft gather dust — wrap it up." + future: "Look at you, shaping the future." + spark: "Every big change needs a first spark — got one? ⚡" + dream: "Dream big. The form can take it. 💭" + coffee: "A coffee and five minutes is all an idea needs. ☕" + small: "No idea is too small to write down." + colleague: "Somewhere a colleague needs exactly your idea. 🤝" + today: "Today’s a great day to start something new." + create: "Be the one who hits the create button. 🚀" + thought: "That thought you keep having? Give it a home here. 📝" + momentum: "One idea today, momentum tomorrow." + curious: "Curious what you’ll come up with next. 👀" + play: + invite: "Psst… up for a game of catch? 🌟" + start: "Play catch" + caught: "Haha, you got me! Now it's my turn 😈" + gotcha: "Gotcha! That was fun. Anyway, back to work. 🎉" + giveup: "Phew… let's call it a draw. 😅" + escaped: "Argh, you got away! Too quick for me. 🏳️" + autosave: saving: Saving… saved: Saved