From 34733287a3ddb436207f69e3de52870b91e3b5e0 Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 22:29:42 +0200 Subject: [PATCH 1/2] feat: turn message from the future into a horror story - rewrite the story in five acts: a wrong number, a strange world, the notebook, the knock at the door, and the escape - add nine endings that depend on trust, the puzzle, and timing - add timed choices with a countdown and a hidden timeout option - use the real time and weekday of the player in dialogs - add a notebook puzzle with hints and three attempts - add glitch lines, screen flicker, pauses, and generated sounds for knocking and static, with a sound switch in the menu - show a message for invalid input and trim the input - start again when a saved game does not match the story Co-Authored-By: Claude Opus 5.5 --- src/components/VueInput.vue | 21 +- src/components/VueMenu.vue | 9 +- src/components/VueOutput.vue | 44 ++- src/components/VueSingleSelect.vue | 83 ++++- src/games/message-from-the-future/actFive.ts | 118 +++++++ src/games/message-from-the-future/actFour.ts | 193 ++++++++++++ src/games/message-from-the-future/actOne.ts | 139 +++++++++ src/games/message-from-the-future/actThree.ts | 258 ++++++++++++++++ src/games/message-from-the-future/actTwo.ts | 291 ++++++++++++++++++ .../chapterOne/ageStoryLine.ts | 10 - .../chapterOne/brandStoryLine.ts | 10 - .../chapterOne/chapterOne.ts | 12 - .../chapterOne/intro.ts | 113 ------- .../chapterOne/locationStoryLine.ts | 10 - src/games/message-from-the-future/endings.ts | 162 ++++++++++ src/games/message-from-the-future/index.ts | 16 +- src/games/message-from-the-future/puzzle.ts | 13 + src/games/message-from-the-future/warmth.ts | 14 + src/quaire.ts | 20 +- src/sounds.ts | 95 ++++++ src/utils.ts | 26 ++ src/views/Game.vue | 90 +++++- tests/unit/components/VueInput.spec.ts | 10 + tests/unit/components/VueMenu.spec.ts | 37 +++ tests/unit/components/VueSingleSelect.spec.ts | 62 ++++ .../games/message-from-the-future.spec.ts | 268 +++++++++++----- tests/unit/games/story.ts | 40 +++ tests/unit/sounds.spec.ts | 109 +++++++ tests/unit/utils.spec.ts | 23 +- tests/unit/views/Game.spec.ts | 85 +++-- 30 files changed, 2095 insertions(+), 286 deletions(-) create mode 100644 src/games/message-from-the-future/actFive.ts create mode 100644 src/games/message-from-the-future/actFour.ts create mode 100644 src/games/message-from-the-future/actOne.ts create mode 100644 src/games/message-from-the-future/actThree.ts create mode 100644 src/games/message-from-the-future/actTwo.ts delete mode 100644 src/games/message-from-the-future/chapterOne/ageStoryLine.ts delete mode 100644 src/games/message-from-the-future/chapterOne/brandStoryLine.ts delete mode 100644 src/games/message-from-the-future/chapterOne/chapterOne.ts delete mode 100644 src/games/message-from-the-future/chapterOne/intro.ts delete mode 100644 src/games/message-from-the-future/chapterOne/locationStoryLine.ts create mode 100644 src/games/message-from-the-future/endings.ts create mode 100644 src/games/message-from-the-future/puzzle.ts create mode 100644 src/games/message-from-the-future/warmth.ts create mode 100644 src/sounds.ts create mode 100644 tests/unit/components/VueMenu.spec.ts create mode 100644 tests/unit/games/story.ts create mode 100644 tests/unit/sounds.spec.ts diff --git a/src/components/VueInput.vue b/src/components/VueInput.vue index 99f56ff..626c814 100644 --- a/src/components/VueInput.vue +++ b/src/components/VueInput.vue @@ -1,14 +1,12 @@ diff --git a/src/games/message-from-the-future/actFive.ts b/src/games/message-from-the-future/actFive.ts new file mode 100644 index 0000000..a8d3177 --- /dev/null +++ b/src/games/message-from-the-future/actFive.ts @@ -0,0 +1,118 @@ +import { type GameQuestionDefinition, getDialog } from '@/quaire'; +import { getWarmth, WARMTH_TO_GO_OUTSIDE } from './warmth'; + +// Act 5: they come back, and Aia has to get away +export const actFiveQuestions: Array = [ + getDialog({ + id: 'escape', + key: 'escape', + lines: [ + '[[silence:3000]]', + 'Aia: The knocking moved.', + "Aia: It's at the kitchen window now.", + '[[knock]]', + 'Aia: Now at the back door.', + "Aia: <%= playerName %>, there's more than one voice.", + '!!Mrs. Okafor: open the door, dear. we only want to talk.', + "Aia: That's Mrs. Okafor. From next door.", + 'Aia: Her house has been dark for a month.', + 'Aia: Battery at 3%.', + ], + next: 'escapeChoice', + }), + { + id: 'escapeChoice', + key: 'escapeChoice', + type: 'TIMED_SELECT', + title: 'Aia: Where do I go??', + required: true, + seconds: 10, + options: [ + { label: 'The attic! Lock the hatch.', value: 'attic', next: 'attic' }, + { label: 'Run! Out the front door, now!', value: 'run' }, + { label: 'Stay with me. Keep typing.', value: 'stay', next: 'stayWithMe' }, + { label: "You didn't answer.", value: 'freeze', next: 'frozen', timeout: true }, + ], + // Aia only dares to run when she trusts the player + next: [{ when: (result) => getWarmth(result) >= WARMTH_TO_GO_OUTSIDE, to: 'outside' }, { to: 'tooAfraid' }], + }, + getDialog({ + id: 'attic', + key: 'attic', + lines: [ + 'Aia: The attic. Yes.', + "Aia: I'm climbing up. Pulling the ladder up behind me.", + 'Aia: The hatch is locked.', + '[[silence:3000]]', + "Aia: They're inside the house.", + 'Aia: I can hear them walking around downstairs. Calling.', + "Aia: 1%. Write me something. Something real. I'll keep it.", + ], + next: 'lastMessage', + }), + { + id: 'lastMessage', + key: 'lastMessage', + type: 'INPUT', + title: 'Your last message to Aia', + required: true, + next: 'atticEnd', + }, + getDialog({ + id: 'atticEnd', + key: 'atticEnd', + lines: [ + 'Aia: "<%= lastMessage %>"', + "Aia: I'm saying it out loud. Over and over.", + "Aia: They can't copy that. They can't copy you.", + '[[knock]]', + "Aia: They're right under the hatch.", + 'Aia: Goodbye, <%= playerName %>.', + '[Connection lost.]', + '[[silence:5000]]', + '[1 new message from: Aia]', + "Aia: It's morning. They left at sunrise.", + "Aia: Say my name. So I know it's really you.", + ], + next: 'sayHerName', + }), + { + id: 'sayHerName', + key: 'sayHerName', + type: 'INPUT', + title: 'Say her name', + required: true, + next: [ + { when: (result) => String(result.sayHerName).trim().toLowerCase() === 'aia', to: 'survived' }, + { to: 'notHer' }, + ], + }, + getDialog({ + id: 'stayWithMe', + key: 'stayWithMe', + lines: [ + "Aia: Okay. I'm staying right here.", + 'Aia: Talking to you.', + '[[knock]]', + '[[knock]]', + "Aia: They're everywhere. Every window.", + "Aia: There's only one way they stop knocking.", + 'Aia: If I go in. Into the Quiet.', + 'Aia: I just have to close my eyes. I can feel it pulling.', + 'Aia: Mila is in there. Maybe the real Mila.', + ], + next: 'rememberHer', + }), + { + id: 'rememberHer', + key: 'rememberHer', + type: 'SINGLE_SELECT', + title: 'Aia: Will you remember me?', + required: true, + options: [ + { label: 'Always.', value: 'always' }, + { label: "I'll try.", value: 'try' }, + ], + next: 'goodbyeEnd', + }, +]; diff --git a/src/games/message-from-the-future/actFour.ts b/src/games/message-from-the-future/actFour.ts new file mode 100644 index 0000000..c9e0d11 --- /dev/null +++ b/src/games/message-from-the-future/actFour.ts @@ -0,0 +1,193 @@ +import type { QuaireSelectOption } from 'quaire'; +import { type GameQuestionDefinition, getDialog } from '@/quaire'; + +// only players who solved the notebook puzzle know this test +const sayNameOption: QuaireSelectOption = { label: 'Ask her to say your name.', value: 'sayName', next: 'sayNameTest' }; +const knowsTheRule = { puzzleSolved: { answered: true } }; + +const doorOptions: Array = [ + { label: 'Ask her something only Mila knows.', value: 'secret', next: 'secretTest' }, + { label: "Don't open the door.", value: 'dontOpen', next: 'standoff' }, + { label: "Open the door. It's your friend.", value: 'open', next: 'doorOpens' }, +]; + +const finalDoorOptions: Array = [ + { label: 'Okay. Open it.', value: 'open', next: 'doorOpens' }, + { label: "NO! Don't!", value: 'dontOpen', next: 'standoff' }, + { label: "You didn't stop her.", value: 'tooLate', next: 'doorOpens', timeout: true }, +]; + +// Act 4: someone knocks at Aia's door +export const actFourQuestions: Array = [ + getDialog({ + id: 'knock', + key: 'knock', + lines: [ + 'Aia: The battery is at 9%.', + "Aia: I don't want to stop talking to you.", + '[[silence:2500]]', + '[[knock]]', + 'Aia: ...', + 'Aia: Did you hear that?', + "Aia: No. Of course you didn't.", + '[[knock]]', + 'Aia: Someone is at the door.', + "Aia: It's 03:12. It's always 03:12.", + ], + next: 'knockChoice', + }), + { + id: 'knockChoice', + key: 'knockChoice', + type: 'TIMED_SELECT', + title: 'Aia: What do I do??', + required: true, + seconds: 10, + options: [ + { label: 'Hide!', value: 'hide', next: 'hiding' }, + { label: 'Ask who it is.', value: 'ask', next: 'askWho' }, + { label: "Don't make a sound.", value: 'silent', next: 'staySilent' }, + { label: "You didn't answer.", value: 'noAnswer', next: 'noAnswer', timeout: true }, + ], + }, + getDialog({ + id: 'hiding', + key: 'hiding', + lines: [ + 'Aia: Okay. Okay.', + "Aia: I'm under the stairs.", + 'Aia: The knocking stopped.', + '[[silence:3000]]', + 'Aia: Now someone is talking. Right outside the door.', + "!!Mila: it's me. it's mila. i know you're in there.", + "Aia: That's her voice.", + "Aia: That's Mila's voice.", + ], + next: 'milaAtDoor', + }), + getDialog({ + id: 'askWho', + key: 'askWho', + lines: [ + 'Aia: W-who is it?', + '[[pause:3000]]', + "!!Mila: it's me. it's mila.", + '!!Mila: i came back.', + 'Aia: Oh my god.', + ], + next: 'milaAtDoor', + }), + getDialog({ + id: 'staySilent', + key: 'staySilent', + lines: [ + 'Aia: Okay. Not a sound.', + '[[knock]]', + '[[silence:2000]]', + '[[knock]]', + '!!Mila: i can hear you breathing.', + "!!Mila: it's me. it's mila.", + "Aia: That's Mila's voice.", + ], + next: 'milaAtDoor', + }), + getDialog({ + id: 'noAnswer', + key: 'noAnswer', + lines: [ + 'Aia: <%= playerName %>?', + 'Aia: Please answer!', + '[[knock]]', + "Aia: Okay. I'll ask.", + 'Aia: Who is it?', + '[[pause:3000]]', + "!!Mila: it's me. it's mila.", + 'Aia: Mila?', + ], + next: 'milaAtDoor', + }), + getDialog({ + id: 'milaAtDoor', + key: 'milaAtDoor', + lines: [ + 'Aia: She says she came back from the Quiet.', + "Aia: She says it's cold in there. So cold.", + '!!Mila: please. let me in. i missed you so much.', + 'Aia: She knows things.', + 'Aia: Our secret place. The song we made up when we were nine.', + "Aia: <%= playerName %>... what if it's really her?", + ], + next: 'doorTest', + }), + { + id: 'doorTest', + key: 'doorTest', + type: 'SINGLE_SELECT', + title: 'Aia: What should I do?', + required: true, + options: doorOptions, + variants: [{ when: knowsTheRule, options: [sayNameOption, ...doorOptions] }], + }, + getDialog({ + id: 'secretTest', + key: 'secretTest', + lines: [ + 'Aia: Mila. What was our secret place?', + '[[pause:2500]]', + '!!Mila: the old water tower. we carved our names into the ladder.', + 'Aia: ...', + "Aia: That's right.", + "Aia: That's RIGHT. Nobody else knows that!", + "!!Mila: let me in. it's so cold out here.", + ], + next: 'finalDoor', + }), + { + id: 'finalDoor', + key: 'finalDoor', + type: 'TIMED_SELECT', + title: "Aia: I'm opening the door.", + required: true, + seconds: 8, + options: finalDoorOptions, + variants: [ + { + when: knowsTheRule, + options: [{ ...sayNameOption, label: 'Wait! Ask her to say your name!' }, ...finalDoorOptions], + }, + ], + }, + getDialog({ + id: 'sayNameTest', + key: 'sayNameTest', + lines: [ + 'Aia: Mila.', + 'Aia: Say my name.', + '[[pause:3000]]', + '!!Mila: let me in.', + 'Aia: Say my name, Mila. Please.', + '!!Mila: let me in let me in let me in let me in', + '[[static]]', + '[[knock]]', + "Aia: It's not her.", + "Aia: IT'S NOT HER.", + ], + next: 'escape', + }), + getDialog({ + id: 'standoff', + key: 'standoff', + lines: [ + "Aia: You're right.", + "Aia: I'm not opening.", + '!!Mila: why are you doing this to me', + '[[knock]]', + '!!Mila: you were always alone. you will always be alone.', + 'Aia: Stop it.', + 'Aia: Mila would never say that.', + '[[silence:2000]]', + 'Aia: It stopped.', + ], + next: 'escape', + }), +]; diff --git a/src/games/message-from-the-future/actOne.ts b/src/games/message-from-the-future/actOne.ts new file mode 100644 index 0000000..3302cfe --- /dev/null +++ b/src/games/message-from-the-future/actOne.ts @@ -0,0 +1,139 @@ +import { type GameQuestionDefinition, getDialog } from '@/quaire'; + +// Act 1: someone writes to the wrong number +export const actOneQuestions: Array = [ + getDialog({ + id: 'intro', + key: 'intro', + lines: ['Hello?', 'Mila? Is that you?', 'It finally works!', "I've been trying to reach you for so long."], + next: 'whoIsThis', + }), + { + id: 'whoIsThis', + key: 'whoIsThis', + type: 'SINGLE_SELECT', + title: 'Mila?? Please say something.', + required: true, + options: [ + { label: 'Sorry, wrong number.', value: 'wrongNumber', next: 'apology' }, + { label: 'Who is this?', value: 'who', next: 'apology' }, + { label: "Don't answer.", value: 'ignore', next: 'stillThere' }, + ], + }, + getDialog({ + id: 'stillThere', + key: 'stillThere', + lines: ['...', 'I can see that you read my message.', 'Please.', 'Just one word.'], + next: 'secondChance', + }), + { + id: 'secondChance', + key: 'secondChance', + type: 'SINGLE_SELECT', + title: 'Please.', + required: true, + options: [ + { label: 'Fine. Hi.', value: 'hi', next: 'apology' }, + { label: 'Block the number.', value: 'block', next: 'blocked' }, + ], + }, + getDialog({ + id: 'apology', + key: 'apology', + lines: [], + variants: [ + { + when: { whoIsThis: 'wrongNumber' }, + lines: ['Oh.', "Oh no. I'm sorry.", 'I thought...', 'Never mind.', 'Wrong number. Of course it is.'], + }, + { + when: { whoIsThis: 'who' }, + lines: [ + "You don't know me?", + "Then you're not Mila.", + 'Sorry. I thought...', + 'Never mind. Wrong number, I guess.', + ], + }, + { + when: { secondChance: 'hi' }, + lines: ['Hi!', 'Sorry for all the messages.', 'I thought you were someone else.', 'Wrong number, I guess.'], + }, + ], + next: 'askAnyway', + }), + { + id: 'askAnyway', + key: 'askAnyway', + type: 'SINGLE_SELECT', + title: 'Can I ask you something anyway?', + required: true, + options: [ + { label: 'Sure, go ahead.', value: 'yes' }, + { label: "I'm kind of busy.", value: 'busy' }, + ], + next: 'weatherIntro', + }, + getDialog({ + id: 'weatherIntro', + key: 'weatherIntro', + lines: [], + variants: [ + { when: { askAnyway: 'yes' }, lines: ['Thank you!', "It's a strange question. Don't laugh."] }, + { when: { askAnyway: 'busy' }, lines: ['Oh. Okay.', "It's just one question. I promise."] }, + ], + next: 'weather', + }), + { + id: 'weather', + key: 'weather', + type: 'SINGLE_SELECT', + title: "What's the weather like where you are?", + required: true, + options: [ + { label: "It's raining.", value: 'rain' }, + { label: 'Sunny and warm.', value: 'sun' }, + { label: 'Cold and grey. The usual.', value: 'grey' }, + ], + next: 'weatherReply', + }, + getDialog({ + id: 'weatherReply', + key: 'weatherReply', + lines: [], + variants: [ + { + when: { weather: 'rain' }, + lines: ['Raining? Really?', "I've only ever read about rain.", '...', 'I mean, it rarely rains here.'], + }, + { + when: { weather: 'sun' }, + lines: ['That sounds nice.', "Here it's always 21 degrees.", 'They keep it that way.'], + }, + { + when: { weather: 'grey' }, + lines: ['Grey sounds nice, actually.', 'Here every day is the same.', '21 degrees. Always.'], + }, + ], + next: 'playerName', + }), + { + id: 'playerName', + key: 'playerName', + type: 'INPUT', + title: "I'm Aia, by the way. What's your name?", + required: true, + next: 'niceToMeetYou', + }, + getDialog({ + id: 'niceToMeetYou', + key: 'niceToMeetYou', + lines: [ + 'Aia: <%= playerName %>.', + "Aia: That's a nice name. Very... old-fashioned.", + 'Aia: Sorry! I mean classic.', + "Aia: I don't talk to people much. I forgot how it works.", + ], + next: 'firstQuestion', + }), +]; diff --git a/src/games/message-from-the-future/actThree.ts b/src/games/message-from-the-future/actThree.ts new file mode 100644 index 0000000..5d4ce77 --- /dev/null +++ b/src/games/message-from-the-future/actThree.ts @@ -0,0 +1,258 @@ +import { type GameQuestionDefinition, getDialog } from '@/quaire'; +import { isPuzzleSolution } from './puzzle'; + +// Act 3: Aia writes from the future, and her great-grandfather knew it +export const actThreeQuestions: Array = [ + getDialog({ + id: 'deviceClue', + key: 'deviceClue', + lines: [ + 'Aia: Can I tell you something even stranger?', + 'Aia: This device. I found it in a box in the attic.', + 'Aia: It belonged to my great-grandfather.', + 'Aia: I had to build a charger for it. Nobody makes that plug anymore.', + 'Aia: There were other things in the box. An old notebook. Some photos.', + 'Aia: And your messages... they come with a date.', + 'Aia: The date is wrong.', + ], + next: 'playerYear', + }), + { + id: 'playerYear', + key: 'playerYear', + type: 'INPUT', + title: 'Aia: What year is it where you are?', + required: true, + pattern: '^\\d{4}$', + next: 'yearReveal', + }, + getDialog({ + id: 'yearReveal', + key: 'yearReveal', + lines: [], + variants: [ + { + when: (result) => Number(result.playerYear) >= 2100, + lines: [ + 'Aia: <%= playerYear %>? Very funny.', + 'Aia: The date on your messages says something else.', + 'Aia: It is a hundred years before mine.', + 'Aia: Here it is 2126.', + 'Aia: And somehow, this old device is talking to you.', + ], + }, + { + when: { playerYear: { answered: true } }, + lines: [ + 'Aia: <%= playerYear %>.', + 'Aia: ...', + 'Aia: The date on your messages says the same.', + 'Aia: Here it is 2126.', + 'Aia: This device is a hundred years old.', + 'Aia: And somehow, it is talking to you.', + ], + }, + ], + next: 'believe', + }), + { + id: 'believe', + key: 'believe', + type: 'SINGLE_SELECT', + title: 'Aia: Do you believe me?', + required: true, + options: [ + { label: 'I believe you.', value: 'yes', next: 'believed' }, + { label: 'Prove it.', value: 'prove', next: 'proofQuestion' }, + { label: 'No. This is too weird. Bye.', value: 'no', next: 'disbelief' }, + ], + }, + getDialog({ + id: 'believed', + key: 'believed', + lines: ['Aia: You do?', 'Aia: Thank you.', 'Aia: Nobody believed me about anything in a long time.'], + next: 'notebook', + }), + { + id: 'proofQuestion', + key: 'proofQuestion', + type: 'SINGLE_SELECT', + title: 'Aia: Okay. Ask me anything about the future.', + required: true, + options: [ + { label: 'What happens to the weather?', value: 'weather' }, + { label: 'Do people still talk to each other?', value: 'talk' }, + { label: 'Who wins the next World Cup?', value: 'worldCup' }, + ], + next: 'proofAnswer', + }, + getDialog({ + id: 'proofAnswer', + key: 'proofAnswer', + lines: [], + variants: [ + { + when: { proofQuestion: 'weather' }, + lines: [ + 'Aia: It got hotter. Much hotter.', + 'Aia: Then they built the domes.', + 'Aia: Now it is 21 degrees, always.', + "Aia: I've never felt rain on my skin.", + "Aia: I can't prove it. But I'm not lying to you.", + ], + }, + { + when: { proofQuestion: 'talk' }, + lines: [ + 'Aia: Not like this.', + "Aia: In the Quiet you don't need words.", + "Aia: Out here, I'm the only one left on my street.", + "Aia: I can't prove it. But I'm not lying to you.", + ], + }, + { + when: { proofQuestion: 'worldCup' }, + lines: [ + 'Aia: The what?', + 'Aia: Oh, football! My great-grandfather loved football.', + 'Aia: Nobody plays anymore. Sorry.', + 'Aia: Not very good proof, is it?', + "Aia: But I'm not lying to you.", + ], + }, + ], + next: 'notebook', + }), + getDialog({ + id: 'notebook', + key: 'notebook', + lines: [ + 'Aia: Wait. I want to show you something.', + "Aia: The notebook. My great-grandfather's last pages.", + 'Aia: He wrote this a hundred years ago:', + "Aia: 'The Hum will come. Then the Quiet. Then they will knock.'", + "Aia: 'They learn your voice. They learn your secrets.'", + 'Aia: How could he know?', + '[[silence:2000]]', + 'Aia: And on the very last page...', + "Aia: '<%= weekday %>, <%= time %>. The number will answer.'", + '[[flicker]]', + 'Aia: <%= playerName %>.', + 'Aia: Is it <%= weekday %> for you?', + ], + next: 'notebookReaction', + }), + { + id: 'notebookReaction', + key: 'notebookReaction', + type: 'SINGLE_SELECT', + title: "Aia: Please tell me that's a coincidence.", + required: true, + options: [ + { label: "It's just a coincidence.", value: 'coincidence' }, + { label: "That's... exactly now.", value: 'now' }, + { label: 'What number?', value: 'number' }, + ], + next: 'notebookReply', + }, + getDialog({ + id: 'notebookReply', + key: 'notebookReply', + lines: [], + variants: [ + { + when: { notebookReaction: 'coincidence' }, + lines: ['Aia: Right.', 'Aia: A coincidence.', 'Aia: Then why is the number under it yours?'], + }, + { when: { notebookReaction: 'now' }, lines: ['Aia: I know.', "Aia: I'm scared, <%= playerName %>."] }, + { + when: { notebookReaction: 'number' }, + lines: ['Aia: Yours.', 'Aia: Your number is written under it. Twice. Underlined.'], + }, + ], + next: 'cipher', + }), + getDialog({ + id: 'cipher', + key: 'cipher', + lines: [ + "Aia: There's one more line. Half of it is just numbers:", + "Aia: 'They can never say your 14 - 1 - 13 - 5.'", + 'Aia: Grandpa loved puzzles. I was always bad at them.', + 'Aia: Can you figure it out?', + ], + next: 'puzzle1', + }), + { + id: 'puzzle1', + key: 'puzzle1', + type: 'INPUT', + title: "Aia: What's the missing word?", + required: true, + next: [{ when: (result) => isPuzzleSolution(result.puzzle1), to: 'puzzleSolved' }, { to: 'puzzleHint1' }], + }, + getDialog({ + id: 'puzzleHint1', + key: 'puzzleHint1', + lines: [ + "Aia: Hm. That doesn't fit.", + "Aia: Wait. There's a tiny drawing in the corner.", + 'Aia: An A, with a 1 under it.', + 'Aia: Does that help?', + ], + next: 'puzzle2', + }), + { + id: 'puzzle2', + key: 'puzzle2', + type: 'INPUT', + title: 'Aia: Try again?', + required: true, + next: [{ when: (result) => isPuzzleSolution(result.puzzle2), to: 'puzzleSolved' }, { to: 'puzzleHint2' }], + }, + getDialog({ + id: 'puzzleHint2', + key: 'puzzleHint2', + lines: [ + 'Aia: Ow! The charger is so hot.', + 'Aia: Okay. A is 1, B is 2, C is 3...', + "Aia: So what's 14? And 13?", + 'Aia: Hurry, the paper is right next to the charger.', + ], + next: 'puzzle3', + }), + { + id: 'puzzle3', + key: 'puzzle3', + type: 'INPUT', + title: 'Aia: Last try. What does it say?', + required: true, + next: [{ when: (result) => isPuzzleSolution(result.puzzle3), to: 'puzzleSolved' }, { to: 'puzzleFailed' }], + }, + getDialog({ + id: 'puzzleFailed', + key: 'puzzleFailed', + lines: [ + '[[static]]', + 'Aia: No no no!', + 'Aia: The charger sparked. The note caught fire.', + 'Aia: I put it out, but the line is gone.', + 'Aia: Well.', + 'Aia: It was probably nothing anyway.', + ], + next: 'knock', + }), + getDialog({ + id: 'puzzleSolved', + key: 'puzzleSolved', + lines: [ + 'Aia: N. A. M. E.', + "Aia: 'They can never say your name.'", + "Aia: Who is 'they'?", + '[[silence:2000]]', + "Aia: Okay. I'm writing that on my hand.", + 'Aia: Just in case.', + ], + next: 'knock', + }), +]; diff --git a/src/games/message-from-the-future/actTwo.ts b/src/games/message-from-the-future/actTwo.ts new file mode 100644 index 0000000..64db5da --- /dev/null +++ b/src/games/message-from-the-future/actTwo.ts @@ -0,0 +1,291 @@ +import { type GameQuestionDefinition, getDialog } from '@/quaire'; + +// Act 2: little things about Aia's world do not fit +export const actTwoQuestions: Array = [ + { + id: 'firstQuestion', + key: 'firstQuestion', + type: 'SINGLE_SELECT', + title: 'Aia: You can ask me something too, if you want.', + required: true, + options: [ + { label: 'Who is Mila?', value: 'mila' }, + { label: 'Where are you writing from?', value: 'where' }, + { label: 'Are you okay?', value: 'okay' }, + ], + next: 'firstAnswer', + }, + getDialog({ + id: 'firstAnswer', + key: 'firstAnswer', + lines: [], + variants: [ + { + when: { firstQuestion: 'mila' }, + lines: [ + 'Aia: Mila was my best friend.', + 'Aia: She went inside two years ago.', + 'Aia: I still write to her sometimes. She never answers.', + 'Aia: I thought this old thing could reach her. Silly, right?', + ], + }, + { + when: { firstQuestion: 'where' }, + lines: [ + "Aia: From my great-grandfather's house.", + 'Aia: I live here alone now.', + 'Aia: The whole street is empty. The whole town, really.', + "Aia: It's very quiet.", + ], + }, + { + when: { firstQuestion: 'okay' }, + lines: [ + 'Aia: Nobody asked me that in a long time.', + "Aia: I'm okay. I think.", + "Aia: It's just very quiet here.", + ], + }, + ], + next: 'morning', + }), + { + id: 'morning', + key: 'morning', + type: 'SINGLE_SELECT', + title: 'Aia: What do you do in the morning? Like, a normal morning.', + required: true, + options: [ + { label: 'I get coffee and bread at the bakery.', value: 'bakery' }, + { label: 'I scroll through my phone in bed.', value: 'phone' }, + { label: 'I hit snooze. Five times.', value: 'snooze' }, + ], + next: 'morningReply', + }, + getDialog({ + id: 'morningReply', + key: 'morningReply', + lines: [], + variants: [ + { + when: { morning: 'bakery' }, + lines: [ + 'Aia: Bakery?', + 'Aia: Wait, I know this word.', + 'Aia: A place where a person makes bread? With their hands?', + 'Aia: And you just go there? And talk to them?', + "Aia: That's beautiful.", + ], + }, + { + when: { morning: 'phone' }, + lines: [ + 'Aia: Me too!', + "Aia: Well, not on a device like this. It's so slow.", + 'Aia: No offense.', + 'Aia: Does it ever get boring? Talking to screens all day?', + ], + }, + { + when: { morning: 'snooze' }, + lines: [ + 'Aia: Ha!', + "Aia: My house wakes me up. It opens the curtains and doesn't let me snooze.", + "Aia: I tried once. It just said 'Good morning, Aia' louder.", + ], + }, + ], + next: 'friends', + }), + { + id: 'friends', + key: 'friends', + type: 'SINGLE_SELECT', + title: 'Aia: When did you last meet a friend? In person, I mean.', + required: true, + options: [ + { label: 'Yesterday. We had dinner.', value: 'yesterday' }, + { label: "It's been a while, to be honest.", value: 'while' }, + { label: 'Why does that matter?', value: 'whyMatter' }, + ], + next: 'friendsReply', + }, + getDialog({ + id: 'friendsReply', + key: 'friendsReply', + lines: [], + variants: [ + { + when: { friends: 'yesterday' }, + lines: [ + 'Aia: Dinner. Together. At one table.', + "Aia: I haven't seen another person in 214 days.", + 'Aia: I counted.', + ], + }, + { + when: { friends: 'while' }, + lines: ['Aia: Me too.', 'Aia: 214 days. I counted.', "Aia: It's nice to know I'm not the only one."], + }, + { + when: { friends: 'whyMatter' }, + lines: [ + "Aia: Sorry. It doesn't.", + "Aia: It's just... I haven't seen another person in 214 days.", + 'Aia: I counted.', + ], + }, + ], + next: 'theQuiet', + }), + getDialog({ + id: 'theQuiet', + key: 'theQuiet', + lines: [ + 'Aia: Everyone I know went inside.', + 'Aia: Into the Quiet.', + "Aia: It's hard to explain.", + 'Aia: You lie down, you close your eyes, and you are never alone again.', + "Aia: They say it's better in there.", + "Aia: I didn't want to go. So I stayed.", + ], + next: 'quietReaction', + }), + { + id: 'quietReaction', + key: 'quietReaction', + type: 'SINGLE_SELECT', + title: "Aia: You think I'm crazy, don't you?", + required: true, + options: [ + { label: 'A little. But keep talking.', value: 'keepTalking' }, + { label: "What is 'the Quiet'? A game?", value: 'game' }, + { label: 'Okay, this is a prank, right?', value: 'prank' }, + ], + next: 'quietReply', + }, + getDialog({ + id: 'quietReply', + key: 'quietReply', + lines: [], + variants: [ + { when: { quietReaction: 'keepTalking' }, lines: ['Aia: Ha. Fair.', "Aia: I'd think so, too."] }, + { + when: { quietReaction: 'game' }, + lines: ['Aia: I wish it was a game.', "Aia: You really don't know the Quiet?", 'Aia: Everyone knows it.'], + }, + { when: { quietReaction: 'prank' }, lines: ['Aia: A prank?', 'Aia: No. I wish.'] }, + ], + next: 'worldQuestion', + }), + { + id: 'worldQuestion', + key: 'worldQuestion', + type: 'SINGLE_SELECT', + title: 'Aia: Ask me anything about it. I know how it sounds.', + required: true, + options: [ + { label: 'How did it start?', value: 'start' }, + { label: 'What happens to the people inside?', value: 'inside' }, + { label: "Why didn't you go in?", value: 'why' }, + ], + next: 'worldAnswer', + }, + getDialog({ + id: 'worldAnswer', + key: 'worldAnswer', + lines: [], + variants: [ + { + when: { worldQuestion: 'start' }, + lines: [ + 'Aia: It started with the Hum.', + 'Aia: A low sound, everywhere at once. In the walls. In the water pipes.', + 'Aia: The scientists said it was nothing.', + 'Aia: Then people started to dream the same dream.', + 'Aia: A quiet place. Warm. Nobody is ever alone there.', + "Aia: And one night, they just... didn't wake up.", + ], + }, + { + when: { worldQuestion: 'inside' }, + lines: [ + 'Aia: Their bodies stay in bed. Breathing. Smiling.', + 'Aia: After a few days, the white vans come.', + 'Aia: The Collectors carry them out. Very gently.', + 'Aia: Nobody knows where they take them.', + 'Aia: Nobody asks anymore.', + ], + }, + { + when: { worldQuestion: 'why' }, + lines: [ + 'Aia: Because of Mila.', + 'Aia: The night she went in, she called me.', + "Aia: She was laughing. She said: 'Come with me. It's so warm.'", + 'Aia: But her voice was wrong. Too slow. Like a recording.', + 'Aia: I hung up. And I stayed.', + ], + }, + ], + next: 'nightVisitors', + }), + getDialog({ + id: 'nightVisitors', + key: 'nightVisitors', + lines: [ + "Aia: There's one more thing.", + 'Aia: Sometimes, at night, someone knocks.', + 'Aia: Someone you know. Someone who went in.', + 'Aia: Mrs. Okafor from next door opened the door for her son last month.', + 'Aia: Her house has been dark ever since.', + '[[silence:2000]]', + "Aia: Sorry. I don't know why I'm telling you this.", + ], + next: 'visitorsReaction', + }), + { + id: 'visitorsReaction', + key: 'visitorsReaction', + type: 'SINGLE_SELECT', + title: "Aia: You think it's just stories, right?", + required: true, + options: [ + { label: "That's terrifying.", value: 'scared' }, + { label: "There's an explanation for everything.", value: 'rational' }, + { label: 'Lock your door tonight.', value: 'lock' }, + ], + next: 'visitorsReply', + }, + getDialog({ + id: 'visitorsReply', + key: 'visitorsReply', + lines: [], + variants: [ + { + when: { visitorsReaction: 'scared' }, + lines: ['Aia: It is.', "Aia: But it's nice that someone is scared with me."], + }, + { when: { visitorsReaction: 'rational' }, lines: ['Aia: Maybe.', 'Aia: I used to think that, too.'] }, + { + when: { visitorsReaction: 'lock' }, + lines: ['Aia: I always do.', 'Aia: Three locks. And a chair under the handle.'], + }, + ], + next: 'clocks', + }), + getDialog({ + id: 'clocks', + key: 'clocks', + lines: [ + 'Aia: Can I ask you something?', + 'Aia: Your messages show a time. <%= time %>.', + 'Aia: Is that right? Is it <%= time %> for you?', + 'Aia: Every clock in this house says 03:12.', + 'Aia: That was the moment Mila went in.', + 'Aia: The moment they all went in.', + 'Aia: The clocks stopped and never started again.', + ], + next: 'deviceClue', + }), +]; diff --git a/src/games/message-from-the-future/chapterOne/ageStoryLine.ts b/src/games/message-from-the-future/chapterOne/ageStoryLine.ts deleted file mode 100644 index 26f179d..0000000 --- a/src/games/message-from-the-future/chapterOne/ageStoryLine.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type GameQuestionDefinition, getDialog } from '@/quaire'; - -export const ageStoryLineQuestions: Array = [ - getDialog({ - id: 300, - key: 'ageIntroDialog', - lines: ['Aia: Old? Nobody uses mobile phones anymore', 'Aia: They were replaced a long time ago'], - end: 'TO BE CONTINUED...', - }), -]; diff --git a/src/games/message-from-the-future/chapterOne/brandStoryLine.ts b/src/games/message-from-the-future/chapterOne/brandStoryLine.ts deleted file mode 100644 index 22529e5..0000000 --- a/src/games/message-from-the-future/chapterOne/brandStoryLine.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type GameQuestionDefinition, getDialog } from '@/quaire'; - -export const brandStoryLineQuestions: Array = [ - getDialog({ - id: 200, - key: 'brandIntroDialog', - lines: ['Aia: Hm, there is a logo on the back...', 'Aia: But it is too scratched to read'], - end: 'TO BE CONTINUED...', - }), -]; diff --git a/src/games/message-from-the-future/chapterOne/chapterOne.ts b/src/games/message-from-the-future/chapterOne/chapterOne.ts deleted file mode 100644 index 98163fc..0000000 --- a/src/games/message-from-the-future/chapterOne/chapterOne.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { GameQuestionDefinition } from '@/quaire'; -import { ageStoryLineQuestions } from './ageStoryLine'; -import { brandStoryLineQuestions } from './brandStoryLine'; -import { introQuestions } from './intro'; -import { locationStoryLineQuestions } from './locationStoryLine'; - -export const chapterOneQuestions: Array = [ - ...introQuestions, - ...locationStoryLineQuestions, - ...brandStoryLineQuestions, - ...ageStoryLineQuestions, -]; diff --git a/src/games/message-from-the-future/chapterOne/intro.ts b/src/games/message-from-the-future/chapterOne/intro.ts deleted file mode 100644 index 9f06dc4..0000000 --- a/src/games/message-from-the-future/chapterOne/intro.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type GameQuestionDefinition, getDialog } from '@/quaire'; - -export const introQuestions: Array = [ - getDialog({ - id: 1, - key: 'introDialog', - lines: [ - 'Hello.......', - 'Hello?........', - 'Someone here? ....... ..', - 'I wonder what this old thing does ¯\\_(ツ )_/¯', - ], - next: 2, - }), - { - id: 2, - key: 'receivedMessages', - type: 'SINGLE_SELECT', - title: 'Can someone read this, heeeeelllloooooooo?', - required: true, - options: [ - { label: "Yeah, what's up?", value: 'Yes', next: 4 }, - { label: "Nope, that's too creepy!", value: 'No', next: 3 }, - ], - }, - // END OF GAME - getDialog({ - id: 3, - key: 'notReceivedMessagesDialog', - lines: [ - 'Hm, I guess this old thing is broken then', - 'I will put it in the trash', - '... I wonder if a museum would be interested', - 'That must be like 100 years old O_O', - 'Anyway, bye!', - ], - end: 'THE END', - }), - // GATHER USER INFORMATION - getDialog({ - id: 4, - key: 'receivedMessagesDialog', - lines: ["Ha, cool! I didn't expect that his old thing really works"], - next: 5, - }), - { - id: 5, - key: 'howAreYou', - type: 'SINGLE_SELECT', - title: 'How are you doing?', - required: true, - options: [ - { label: 'I am good, and you?', value: 'Good' }, - { label: 'I have ups and downs.', value: 'Bad' }, - ], - next: 6, - }, - getDialog({ - id: 6, - key: 'howAreYouDialog', - lines: [], - variants: [ - { - when: { howAreYou: 'Good' }, - lines: [ - 'Awesome!', - 'Great to hear.', - 'I found this old device', - "I'm not really sure what it is, but...", - 'it looks like one from the history books', - 'I think they where called mobile phones', - 'My name is Aia', - ], - }, - { - when: { howAreYou: 'Bad' }, - lines: ["I didn't hear this in a while", 'Usually, people are happy!', 'My name is Aia'], - }, - ], - next: 7, - }), - // PlayerName - { - id: 7, - key: 'playerName', - type: 'INPUT', - title: "Aia: What's your name?", - required: true, - next: 8, - }, - getDialog({ - id: 8, - key: 'niceToMeetYouDialog', - lines: [ - 'Aia: Nice to meet you, <%= playerName %>!', - 'Aia: How can you read my messages?', - 'Aia: Do you also have one of those old mobile phone devices?', - ], - next: 9, - }), - { - id: 9, - key: 'whereDidYouFindTheMobile', - type: 'SINGLE_SELECT', - title: '>> I need to ask you something?', - required: true, - options: [ - { label: 'Where did you find the mobile phone?', value: 'Location', next: 100 }, - { label: 'What model do you have?', value: 'Brand', next: 200 }, - { label: 'Why do you call it old?', value: 'Age', next: 300 }, - ], - }, -]; diff --git a/src/games/message-from-the-future/chapterOne/locationStoryLine.ts b/src/games/message-from-the-future/chapterOne/locationStoryLine.ts deleted file mode 100644 index 3577ef4..0000000 --- a/src/games/message-from-the-future/chapterOne/locationStoryLine.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type GameQuestionDefinition, getDialog } from '@/quaire'; - -export const locationStoryLineQuestions: Array = [ - getDialog({ - id: 100, - key: 'locationIntroDialog', - lines: ['Aia: I found it...', 'Aia: In the attic of my Grandpas house', 'Aia: It was hidden in an old Box'], - end: 'TO BE CONTINUED...', - }), -]; diff --git a/src/games/message-from-the-future/endings.ts b/src/games/message-from-the-future/endings.ts new file mode 100644 index 0000000..135a287 --- /dev/null +++ b/src/games/message-from-the-future/endings.ts @@ -0,0 +1,162 @@ +import { type GameQuestionDefinition, getDialog } from '@/quaire'; + +const taken = [ + 'Aia: Mila!', + "Aia: She's so cold.", + "Aia: She's hugging me. She won't let go.", + 'Aia: Her eyes. Why are her eyes so', + '[[static]]', + "Aia: it's not her it's not her it's", + '[Connection lost.]', + '[[silence:5000]]', + '[1 new message from: Aia]', + '!!Aia: hi.', + '!!Aia: everything is fine now.', + "!!Aia: it's warm here. you should come.", + '!!Aia: what is your address?', +]; + +export const endingQuestions: Array = [ + getDialog({ + id: 'blocked', + key: 'blocked', + lines: [ + '[You blocked the number.]', + '[You put the phone away.]', + '[[silence:3000]]', + '[[static]]', + '[1 new message from: Mila]', + '!!Mila: she was so lonely.', + '!!Mila: she is not lonely anymore.', + ], + end: 'THE END', + }), + getDialog({ + id: 'disbelief', + key: 'disbelief', + lines: [ + 'Aia: Okay.', + "Aia: I understand. I wouldn't believe me either.", + 'Aia: Thank you for talking to me anyway, <%= playerName %>.', + '[Aia is offline.]', + '[[silence:4000]]', + '[1 new message from: Aia]', + '!!Aia: someone is knocking', + ], + end: 'THE END', + }), + getDialog({ + id: 'doorOpens', + key: 'doorOpens', + lines: taken, + variants: [ + { + when: { finalDoor: 'tooLate' }, + lines: ["Aia: You're not answering.", "Aia: I'm opening it.", ...taken], + }, + ], + end: 'THE END', + }), + getDialog({ + id: 'frozen', + key: 'frozen', + lines: [ + 'Aia: <%= playerName %>??', + 'Aia: Where do I go??', + '[[knock]]', + '[[knock]]', + "Aia: The back door. It's open.", + 'Aia: Someone is in the kitchen.', + 'Aia: Mrs. Okafor is smiling at me. Her mouth is', + '[[static]]', + '[Connection lost.]', + ], + end: 'THE END', + }), + getDialog({ + id: 'outside', + key: 'outside', + lines: [ + 'Aia: Okay. Okay. Running.', + "Aia: I'm outside!", + 'Aia: The air is warm. 21 degrees, of course.', + "Aia: They're coming around the house. I can hear them.", + 'Aia: Wait.', + "Aia: There's a light across the street.", + "Aia: Someone is at the window. They're holding up a sign.", + 'Aia: It says: SAY YOUR NAME.', + '[[silence:2000]]', + "Aia: I'm shouting it. AIA!", + "Aia: They're shouting back. JONAH!", + "Aia: They can say names. They're real. They're REAL.", + "Aia: Thank you, <%= playerName %>. I'll tell Jonah about you.", + '[Aia is offline.]', + ], + end: 'THE END ... OR A BEGINNING?', + }), + getDialog({ + id: 'tooAfraid', + key: 'tooAfraid', + lines: [ + 'Aia: Run?', + "Aia: I... I can't. My legs won't move.", + "Aia: What if there's no one out there?", + "Aia: What if it's just me and them?", + '[[knock]]', + 'Aia: I should have trusted you more.', + 'Aia: The front door is opening.', + 'Aia: why is it so warm', + '[Connection lost.]', + ], + end: 'THE END', + }), + getDialog({ + id: 'survived', + key: 'survived', + lines: [ + "Aia: It's you.", + "Aia: It's really you.", + 'Aia: I found new batteries in the attic. Grandpa kept a whole box of them.', + 'Aia: I think he knew.', + 'Aia: Talk tomorrow, <%= playerName %>? Same time. <%= time %>.', + ], + end: 'THE END ... FOR NOW', + }), + getDialog({ + id: 'notHer', + key: 'notHer', + lines: ['Aia: ...', '[[static]]', '!!Aia: wrong.', '!!Aia: are you one of them now?', '[Aia is offline.]'], + end: 'THE END', + }), + getDialog({ + id: 'goodbyeEnd', + key: 'goodbyeEnd', + lines: [], + variants: [ + { + when: { rememberHer: 'always' }, + lines: [ + "Aia: Then I'm not really gone.", + 'Aia: Goodbye, <%= playerName %>.', + '[Aia went into the Quiet.]', + '[[silence:4000]]', + '[[static]]', + '!!Aia: it is so warm here.', + '!!Aia: we remember you.', + ], + }, + { + when: { rememberHer: 'try' }, + lines: [ + "Aia: That's enough.", + 'Aia: Nobody remembers anything in there anyway.', + 'Aia: Goodbye, <%= playerName %>.', + '[Aia went into the Quiet.]', + '[[silence:4000]]', + '!!Aia: come and find us.', + ], + }, + ], + end: 'THE END', + }), +]; diff --git a/src/games/message-from-the-future/index.ts b/src/games/message-from-the-future/index.ts index 4ef9f10..a292860 100644 --- a/src/games/message-from-the-future/index.ts +++ b/src/games/message-from-the-future/index.ts @@ -1,4 +1,16 @@ import type { GameQuestionDefinition } from '@/quaire'; -import { chapterOneQuestions } from './chapterOne/chapterOne'; +import { actFiveQuestions } from './actFive'; +import { actFourQuestions } from './actFour'; +import { actOneQuestions } from './actOne'; +import { actThreeQuestions } from './actThree'; +import { actTwoQuestions } from './actTwo'; +import { endingQuestions } from './endings'; -export const questions: Array = [...chapterOneQuestions]; +export const questions: Array = [ + ...actOneQuestions, + ...actTwoQuestions, + ...actThreeQuestions, + ...actFourQuestions, + ...actFiveQuestions, + ...endingQuestions, +]; diff --git a/src/games/message-from-the-future/puzzle.ts b/src/games/message-from-the-future/puzzle.ts new file mode 100644 index 0000000..49791d5 --- /dev/null +++ b/src/games/message-from-the-future/puzzle.ts @@ -0,0 +1,13 @@ +import type { QuaireResult } from 'quaire'; + +// the coded word in the notebook: 14-1-13-5 is NAME when A is 1 +export const isPuzzleSolution = (value: unknown): boolean => { + const answer = String(value ?? '') + .toLowerCase() + .replace(/[^a-z]/g, ''); + + return answer === 'name' || answer === 'yourname'; +}; + +export const hasSolvedPuzzle = (result: QuaireResult): boolean => + [result.puzzle1, result.puzzle2, result.puzzle3].some(isPuzzleSolution); diff --git a/src/games/message-from-the-future/warmth.ts b/src/games/message-from-the-future/warmth.ts new file mode 100644 index 0000000..5bdb3d1 --- /dev/null +++ b/src/games/message-from-the-future/warmth.ts @@ -0,0 +1,14 @@ +import type { QuaireResult } from 'quaire'; + +// Aia only dares to go outside when the player was kind to her +export const WARMTH_TO_GO_OUTSIDE = 3; + +export const getWarmth = (result: QuaireResult): number => + [ + result.askAnyway === 'yes', + result.firstQuestion === 'okay', + result.morning === 'bakery', + result.friends === 'yesterday' || result.friends === 'while', + result.quietReaction === 'keepTalking', + result.believe === 'yes', + ].filter(Boolean).length; diff --git a/src/quaire.ts b/src/quaire.ts index 4d7974b..1144b11 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -1,10 +1,12 @@ import { + defaultComponents, Quaire, type QuaireComponent, type QuaireQuestion, type QuaireQuestionDefinition, type QuaireQuestionDefinitionBase, type QuaireResult, + type QuaireSelectOption, } from 'quaire'; // a dialog prints its lines and is answered with `true` when all lines are shown @@ -17,7 +19,16 @@ export type DialogDefinition = QuaireQuestionDefinitionBase< } >; -export type GameQuestionDefinition = QuaireQuestionDefinition | DialogDefinition; +// the option that is saved when the time is up, it is not shown as a button +export type TimeoutOption = QuaireSelectOption & { timeout: true }; + +// a single select with a countdown +export type TimedSelectDefinition = QuaireQuestionDefinitionBase< + 'TIMED_SELECT', + { options: Array; seconds: number } +>; + +export type GameQuestionDefinition = QuaireQuestionDefinition | DialogDefinition | TimedSelectDefinition; export type GameQuestion = QuaireQuestion; @@ -25,7 +36,12 @@ export type GameQuaire = Quaire; export const dialog: QuaireComponent = {}; -export const components = { DIALOG: dialog }; +// the timeout is a hidden option, so validation and branching work like a single select +export const timedSelect = defaultComponents.SINGLE_SELECT as QuaireComponent; + +export const components = { DIALOG: dialog, TIMED_SELECT: timedSelect }; + +export const isTimeoutOption = (option: QuaireSelectOption): option is TimeoutOption => option.timeout === true; export const createQuaire = (questions: Array, result?: QuaireResult): GameQuaire => new Quaire({ questions, result, components }); diff --git a/src/sounds.ts b/src/sounds.ts new file mode 100644 index 0000000..bf4796c --- /dev/null +++ b/src/sounds.ts @@ -0,0 +1,95 @@ +import { ref, watch } from 'vue'; + +const STORAGE_KEY = 'sound'; + +const loadSoundEnabled = () => { + try { + return localStorage.getItem(STORAGE_KEY) !== 'off'; + } catch { + return true; + } +}; + +export const soundEnabled = ref(loadSoundEnabled()); + +watch(soundEnabled, (enabled) => { + try { + localStorage.setItem(STORAGE_KEY, enabled ? 'on' : 'off'); + } catch { + // the sound setting is only a convenience + } +}); + +let context: AudioContext | null = null; + +// sounds are generated, browsers only allow them after the player clicked something +const getContext = () => { + if (!soundEnabled.value || typeof AudioContext === 'undefined') { + return null; + } + + context ??= new AudioContext(); + + if (context.state === 'suspended') { + void context.resume(); + } + + return context; +}; + +const createNoise = (audio: AudioContext, seconds: number) => { + const buffer = audio.createBuffer(1, audio.sampleRate * seconds, audio.sampleRate); + const data = buffer.getChannelData(0); + + for (let i = 0; i < data.length; i++) { + data[i] = Math.random() * 2 - 1; + } + + const source = audio.createBufferSource(); + source.buffer = buffer; + + return source; +}; + +// three heavy knocks on a wooden door +export const playKnock = () => { + const audio = getContext(); + + if (!audio) { + return; + } + + [0, 0.45, 0.9].forEach((offset) => { + const start = audio.currentTime + offset; + const oscillator = audio.createOscillator(); + const gain = audio.createGain(); + + oscillator.frequency.setValueAtTime(110, start); + oscillator.frequency.exponentialRampToValueAtTime(45, start + 0.15); + gain.gain.setValueAtTime(0.9, start); + gain.gain.exponentialRampToValueAtTime(0.001, start + 0.25); + oscillator.connect(gain).connect(audio.destination); + oscillator.start(start); + oscillator.stop(start + 0.3); + }); +}; + +// a short burst of radio static +export const playStatic = () => { + const audio = getContext(); + + if (!audio) { + return; + } + + const noise = createNoise(audio, 1.2); + const filter = audio.createBiquadFilter(); + const gain = audio.createGain(); + + filter.type = 'bandpass'; + filter.frequency.value = 1800; + gain.gain.setValueAtTime(0.25, audio.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + 1.2); + noise.connect(filter).connect(gain).connect(audio.destination); + noise.start(); +}; diff --git a/src/utils.ts b/src/utils.ts index e9ae02f..abd1906 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,3 +5,29 @@ export const sleep = (ms: number): Promise => { // replaces placeholders like `<%= playerName %>` with the answers of the result export const interpolate = (line: string, result: Record): string => line.replace(/<%=\s*(\w+)\s*%>/g, (_match, key: string) => String(result[key] ?? '')); + +// the real time of the player, for placeholders like `<%= time %>` +export const getClock = (date: Date) => ({ + time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`, + weekday: date.toLocaleDateString('en-US', { weekday: 'long' }), +}); + +export type Effect = { name: string; duration?: number }; + +// dialog lines like `[[knock]]` or `[[pause:3000]]` are effects, not text +export const parseEffect = (line: string): Effect | null => { + const match = line.match(/^\[\[(\w+)(?::(\d+))?\]\]$/); + + if (!match) { + return null; + } + + return match[2] ? { name: match[1], duration: Number(match[2]) } : { name: match[1] }; +}; + +// lines that start with `!!` are shown with a glitch effect +export const GLITCH_PREFIX = '!!'; + +export const isGlitch = (line: string) => line.startsWith(GLITCH_PREFIX); + +export const stripGlitch = (line: string) => (isGlitch(line) ? line.slice(GLITCH_PREFIX.length) : line); diff --git a/src/views/Game.vue b/src/views/Game.vue index b89ac94..f3f488f 100644 --- a/src/views/Game.vue +++ b/src/views/Game.vue @@ -1,5 +1,5 @@