Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/components/VueInput.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<template>
<form @submit.stop.prevent="onSubmit" :class="activeQuestion.title.startsWith('>>') && 'playerMessage'">
<label>{{ activeQuestion.title }}: <input v-model="input" /></label>
<div v-if="submitted && activeQuestion.error === 'REQUIRED'" class="error" role="alert">
Please enter an answer.
</div>
<div v-if="submitted && errorMessage" class="error" role="alert">{{ errorMessage }}</div>
</form>
</template>

<script lang="ts">
import { defineComponent, ref, type PropType } from 'vue';
import { computed, defineComponent, ref, type PropType } from 'vue';
import type { QuaireInputDefinition, QuaireQuestion } from 'quaire';

export default defineComponent({
Expand All @@ -17,18 +15,29 @@ export default defineComponent({
activeQuestion: { type: Object as PropType<QuaireQuestion<QuaireInputDefinition>>, required: true },
},
emits: ['onSubmit'],
setup(_props, { emit }) {
setup(props, { emit }) {
const input = ref('');
// a required question has an error until it is answered, show it after the first submit
const submitted = ref(false);
const errorMessage = computed(() => {
switch (props.activeQuestion.error) {
case 'REQUIRED':
return 'Please enter an answer.';
case 'PATTERN':
return "That doesn't look right. Please try again.";
default:
return null;
}
});
const onSubmit = () => {
submitted.value = true;
emit('onSubmit', input.value);
emit('onSubmit', input.value.trim());
};

return {
input,
submitted,
errorMessage,
onSubmit,
};
},
Expand Down
13 changes: 11 additions & 2 deletions src/components/VueMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
</button>
<button class="clearGame" @click="$emit('clear')">Clear</button>
<button class="resetGame" @click="$emit('reset')">Reset Game</button>
<button class="resetGame" @click="$router.push('/')">Back to Start</button>
<button class="toggleSound wide" @click="soundEnabled = !soundEnabled" :aria-pressed="soundEnabled">
Sound: {{ soundEnabled ? 'On' : 'Off' }}
</button>
<button class="toggleTimers wide" @click="timersEnabled = !timersEnabled" :aria-pressed="timersEnabled">
Timer: {{ timersEnabled ? 'On' : 'Off' }}
</button>
<button class="resetGame wide" @click="$router.push('/')">Back to Start</button>
</div>
</div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
import { soundEnabled, timersEnabled } from '@/settings';

export default defineComponent({
name: 'VueMenu',
Expand All @@ -21,6 +28,8 @@ export default defineComponent({

return {
open,
soundEnabled,
timersEnabled,
};
},
});
Expand Down Expand Up @@ -59,7 +68,7 @@ export default defineComponent({
button {
display: inline-block;

&:last-child {
&.wide {
grid-column-start: 1;
grid-column-end: 4;
}
Expand Down
44 changes: 42 additions & 2 deletions src/components/VueOutput.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,65 @@
<template>
<ul class="output">
<li v-for="(item, idx) in output" :key="idx" :class="item.startsWith('>>') && 'playerMessage'">
{{ item }}
<li
v-for="(item, idx) in output"
:key="idx"
:class="[item.startsWith('>>') && 'playerMessage', isGlitch(item) && 'glitch']"
>
{{ stripGlitch(item) }}
</li>
<li v-if="isTyping" class="dot-pulse loader"></li>
</ul>
</template>

<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { isGlitch, stripGlitch } from '@/utils';

export default defineComponent({
name: 'VueOutput',
props: {
output: { type: Array as PropType<Array<string>>, required: true },
isTyping: { type: Boolean, default: false },
},
setup() {
return { isGlitch, stripGlitch };
},
});
</script>

<style scoped lang="scss">
@keyframes glitch {
0%,
100% {
transform: translate(0);
text-shadow:
2px 0 rgba(255, 0, 80, 0.8),
-2px 0 rgba(0, 30, 255, 0.8);
}
20% {
transform: translate(-2px, 1px);
}
40% {
transform: translate(2px, -1px);
text-shadow:
-3px 0 rgba(255, 0, 80, 0.8),
3px 0 rgba(0, 30, 255, 0.8);
}
60% {
transform: translate(-1px, 0);
opacity: 0.7;
}
}

.glitch {
color: #ff2a55;
animation: glitch 0.4s infinite steps(2);

@media (prefers-reduced-motion: reduce) {
animation: none;
}
}

.output {
padding: 0;
margin: 0;
Expand Down
85 changes: 76 additions & 9 deletions src/components/VueSingleSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,72 @@
{{ activeQuestion.title }}
</div>
<div class="options">
<button
v-for="option in activeQuestion.options"
:key="String(option.value)"
@click="$emit('onSubmit', option)"
tabindex="0"
>
<button v-for="option in options" :key="String(option.value)" @click="onSubmit(option)" tabindex="0">
{{ option.label }}
</button>
</div>
<div
v-if="seconds"
class="countdown"
role="progressbar"
aria-label="Time left"
:aria-valuemax="seconds"
:aria-valuenow="Math.ceil(remaining / 1000)"
>
<div class="bar" :style="{ width: `${(remaining / (seconds * 1000)) * 100}%` }"></div>
<span>{{ Math.ceil(remaining / 1000) }}s</span>
</div>
</div>
</template>

<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { QuaireQuestion, QuaireSingleSelectDefinition } from 'quaire';
import { computed, defineComponent, onMounted, onUnmounted, ref, type PropType } from 'vue';
import type { QuaireQuestion, QuaireSelectOption, QuaireSingleSelectDefinition } from 'quaire';
import { isTimeoutOption, type TimedSelectDefinition } from '@/quaire';
import { timersEnabled } from '@/settings';

const TICK = 100;

export default defineComponent({
name: 'VueSingleSelect',
props: {
activeQuestion: { type: Object as PropType<QuaireQuestion<QuaireSingleSelectDefinition>>, required: true },
activeQuestion: {
type: Object as PropType<QuaireQuestion<QuaireSingleSelectDefinition> | QuaireQuestion<TimedSelectDefinition>>,
required: true,
},
},
emits: ['onSubmit', 'onTimeout'],
setup(props, { emit }) {
const seconds =
props.activeQuestion.type === 'TIMED_SELECT' && timersEnabled.value ? props.activeQuestion.seconds : 0;
const remaining = ref(seconds * 1000);
const options = computed(() => props.activeQuestion.options.filter((option) => !isTimeoutOption(option)));
let timer: ReturnType<typeof setInterval> | undefined;

const stop = () => clearInterval(timer);
const onSubmit = (option: QuaireSelectOption) => {
stop();
emit('onSubmit', option);
};

onMounted(() => {
if (!seconds) {
return;
}

timer = setInterval(() => {
remaining.value = Math.max(0, remaining.value - TICK);

if (remaining.value === 0) {
stop();
emit('onTimeout', props.activeQuestion.options.find(isTimeoutOption));
}
}, TICK);
});

onUnmounted(stop);

return { seconds, remaining, options, onSubmit };
},
});
</script>
Expand All @@ -45,4 +91,25 @@ export default defineComponent({
}
}
}
.countdown {
position: relative;
margin-top: 16px;
height: 20px;
border: 2px solid #ff2a55;

.bar {
height: 100%;
background: #ff2a55;
transition: width 0.1s linear;
}

span {
position: absolute;
top: 0;
right: 8px;
line-height: 16px;
color: rgb(50, 255, 0);
mix-blend-mode: difference;
}
}
</style>
118 changes: 118 additions & 0 deletions src/games/message-from-the-future/actFive.ts
Original file line number Diff line number Diff line change
@@ -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<GameQuestionDefinition> = [
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',
},
];
Loading
Loading