ENH - update SMS branch from dev
@@ -11,6 +11,7 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
|
||||
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
|
||||
@@ -20,11 +21,14 @@ import SimPhonePicker, {
|
||||
} from '@/components/SimPhonePicker.vue'
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import { useClockStore } from '@/stores/clock'
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import {
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
|
||||
import type { PhoneNotificationDevicePayload } from '@/types/device'
|
||||
import type { MailCounts } from '@/types/mail'
|
||||
import type { MarketplaceCounts } from '@/types/marketplace'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { formatTimer } from '@/utils/clock'
|
||||
@@ -43,7 +48,9 @@ import SpringboardView from '@/views/SpringboardView.vue'
|
||||
type AppMessage = {
|
||||
type?: string
|
||||
data?:
|
||||
| CalendarReminderData
|
||||
| MailEventData
|
||||
| MarketplaceEventData
|
||||
| MessagesEventData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
@@ -72,6 +79,24 @@ type MessagesEventData = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
type MarketplaceEventData = {
|
||||
counts?: MarketplaceCounts
|
||||
device?: PhoneNotificationDevicePayload
|
||||
inquiryId?: string
|
||||
listingId?: string
|
||||
sender?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type CalendarReminderData = {
|
||||
device?: PhoneNotificationDevicePayload
|
||||
eventId?: string
|
||||
eventTitle?: string
|
||||
startsAt?: number
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const PHONE_BASE_SCALE = 0.69
|
||||
@@ -80,16 +105,22 @@ const isDevelopment = import.meta.env.DEV
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const clock = useClockStore()
|
||||
const games = useGamesStore()
|
||||
const calls = useCallsStore()
|
||||
const mail = useMailStore()
|
||||
const messages = useMessagesStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const notes = useNotesStore()
|
||||
const weather = useWeatherStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isAppRoute = computed(() => route.name === 'app')
|
||||
const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
@@ -120,15 +151,45 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
account.hydrate(payload.account ?? null)
|
||||
notes.hydrate(payload.notes ?? [])
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
void mail.bootstrap(payload.account?.email ?? '')
|
||||
if (payload.account?.email) void marketplace.loadCounts()
|
||||
else marketplace.setCounts({ active: 0, unread: 0 })
|
||||
void calls.bootstrap()
|
||||
void messages.loadConversations()
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
const response = await nuiCall<PhoneOpenPayload>('development:bootstrap')
|
||||
if (response.success && response.data) {
|
||||
hydratePhone(response.data)
|
||||
return
|
||||
}
|
||||
|
||||
hydratePhone({
|
||||
account: null,
|
||||
device: {
|
||||
data: {},
|
||||
imei: '356938035643809',
|
||||
name: 'iFruit Phone',
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
token: 'development',
|
||||
})
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (event.data?.type === 'app:open') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
void nuiCall('ui:opened')
|
||||
} else if (event.data?.type === 'device:updated') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'app:close') {
|
||||
@@ -166,6 +227,61 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
|
||||
const data = event.data.data as MarketplaceEventData
|
||||
if (data.counts) marketplace.setCounts(data.counts)
|
||||
} else if (
|
||||
event.data?.type === 'marketplace:new-message' &&
|
||||
event.data.data
|
||||
) {
|
||||
const data = event.data.data as MarketplaceEventData
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'citymarkt',
|
||||
subtitle: data.sender,
|
||||
text:
|
||||
data.text ??
|
||||
phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }),
|
||||
title: data.title ?? phone.t('Apps.citymarkt.name'),
|
||||
}
|
||||
if (
|
||||
data.device &&
|
||||
(!phone.isOpen || data.device.imei !== phone.device?.imei)
|
||||
) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
void marketplace.loadCounts()
|
||||
} else if (event.data?.type === 'calendar:reminder' && event.data.data) {
|
||||
const data = event.data.data as CalendarReminderData
|
||||
const startsAt = Number(data.startsAt) || Date.now()
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'calendar',
|
||||
subtitle: new Intl.DateTimeFormat(phone.lang, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(startsAt),
|
||||
text:
|
||||
data.text ??
|
||||
phone.t('Apps.calendar.reminderNotification', {
|
||||
title: data.eventTitle ?? '',
|
||||
}),
|
||||
title: data.title ?? phone.t('Apps.calendar.name'),
|
||||
}
|
||||
if (
|
||||
data.device &&
|
||||
(!phone.isOpen || data.device.imei !== phone.device?.imei)
|
||||
) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
} else if (event.data?.type === 'contacts:changed') {
|
||||
void calls.loadContacts()
|
||||
} else if (event.data?.type === 'messages:changed') {
|
||||
@@ -239,6 +355,11 @@ function unlockPhone(): void {
|
||||
}, 720)
|
||||
}
|
||||
|
||||
function unlockCamera(): void {
|
||||
unlockPhone()
|
||||
window.setTimeout(() => void router.push('/apps/camera'), 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
@@ -274,22 +395,7 @@ onMounted(() => {
|
||||
}
|
||||
}, 1000)
|
||||
if (isDevelopment) {
|
||||
hydratePhone({
|
||||
account: null,
|
||||
device: {
|
||||
data: {},
|
||||
imei: '356938035643809',
|
||||
name: 'iFruit Phone',
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
token: 'development',
|
||||
})
|
||||
void hydrateDevelopmentPhone()
|
||||
if (new URLSearchParams(window.location.search).has('simPickerPreview')) {
|
||||
simPicker.value = {
|
||||
choices: [
|
||||
@@ -350,6 +456,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PhoneMediaCapture />
|
||||
<SimPhonePicker
|
||||
v-if="simPicker"
|
||||
:choices="simPicker.choices"
|
||||
@@ -367,6 +474,7 @@ onBeforeUnmount(() => {
|
||||
class="phone-stage"
|
||||
:class="{
|
||||
'phone-stage--dev': isDevelopment,
|
||||
'phone-stage--landscape': phone.cameraLandscape,
|
||||
'phone-stage--peek': notifications.isPeeking,
|
||||
}"
|
||||
:style="phoneResolutionStyle"
|
||||
@@ -406,13 +514,21 @@ onBeforeUnmount(() => {
|
||||
<PhoneStatusBar v-if="!isLocked" />
|
||||
<SpringboardView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="app-window">
|
||||
<component :is="Component" v-if="isAppRoute" />
|
||||
<Transition :name="appTransitionName">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="isAppRoute"
|
||||
:key="route.path"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<PhoneHomeIndicator v-if="!isLocked" />
|
||||
<Transition name="lock-screen">
|
||||
<PhoneLockScreen v-if="isLocked" @unlock="unlockPhone" />
|
||||
<PhoneLockScreen
|
||||
v-if="isLocked"
|
||||
@camera="unlockCamera"
|
||||
@unlock="unlockPhone"
|
||||
/>
|
||||
</Transition>
|
||||
<PhoneNotifications
|
||||
:notification="notifications.current"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Calendar">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="32" y1="20" x2="224" y2="236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff765f"/>
|
||||
<stop offset="1" stop-color="#e93147"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#8c1021" flood-opacity=".32"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="58" fill="url(#bg)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="42" y="50" width="172" height="164" rx="28" fill="#fff"/>
|
||||
<path d="M42 78c0-15.5 12.5-28 28-28h116c15.5 0 28 12.5 28 28v26H42V78Z" fill="#f8e9eb"/>
|
||||
<rect x="77" y="36" width="14" height="42" rx="7" fill="#fff"/>
|
||||
<rect x="165" y="36" width="14" height="42" rx="7" fill="#fff"/>
|
||||
<g fill="#d5d8df">
|
||||
<rect x="67" y="124" width="26" height="21" rx="7"/><rect x="104" y="124" width="26" height="21" rx="7"/><rect x="141" y="124" width="26" height="21" rx="7"/>
|
||||
<rect x="67" y="157" width="26" height="21" rx="7"/><rect x="141" y="157" width="26" height="21" rx="7"/>
|
||||
</g>
|
||||
<rect x="101" y="153" width="32" height="29" rx="9" fill="#ff4259"/>
|
||||
<path d="m109 167 7 7 11-14" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<circle cx="201" cy="198" r="27" fill="#272b36" stroke="#fff" stroke-width="6"/>
|
||||
<path d="M201 183v16l10 7" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#55c9ec"/>
|
||||
<stop offset="0.58" stop-color="#7f91e4"/>
|
||||
<stop offset="1" stop-color="#5b4da6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tower" x1="330" y1="0" x2="488" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8276e4"/>
|
||||
<stop offset="0.48" stop-color="#6559ca"/>
|
||||
<stop offset="1" stop-color="#433a9c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bird" x1="20" y1="24" x2="90" y2="52" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5fc1d1"/>
|
||||
<stop offset="0.58" stop-color="#287d99"/>
|
||||
<stop offset="1" stop-color="#16546f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" fill="url(#sky)"/>
|
||||
<circle cx="112" cy="105" r="46" fill="#ffe6a5" opacity=".78"/>
|
||||
<path d="M-18 155c20-25 55-28 78-8 15-29 61-27 75 5 26-9 55 7 59 32H-18Z" fill="#fff" opacity=".3"/>
|
||||
<path d="M2 405c25-29 66-30 88-4 19-16 50-9 58 17H2Z" fill="#fff" opacity=".2"/>
|
||||
|
||||
<g stroke="#312b78" stroke-width="9" stroke-linejoin="round">
|
||||
<path d="M357-12h112v171H357Z" fill="url(#tower)"/>
|
||||
<path d="M329 148h168v52H329Z" fill="url(#tower)"/>
|
||||
<path d="M329 323h168v52H329Z" fill="url(#tower)"/>
|
||||
<path d="M357 364h112v160H357Z" fill="url(#tower)"/>
|
||||
</g>
|
||||
<path d="M373 0v145M345 164h132M345 339h132M373 379v133" fill="none" stroke="#aaa2ff" stroke-width="10" stroke-linecap="round" opacity=".62"/>
|
||||
|
||||
<g fill="none" stroke="#e9fbff" stroke-linecap="round" opacity=".7">
|
||||
<path d="M24 257h63" stroke-width="12"/>
|
||||
<path d="M39 288h38" stroke-width="8"/>
|
||||
<path d="M52 224h52" stroke-width="7"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70 202) scale(2.15)" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M50 38C39 29 30 15 34 5c10 4 22 16 29 31Z" fill="#2d7188" stroke="#123f57" stroke-width="2"/>
|
||||
<path d="M29 36 5 25l12 15L3 49l29-4Z" fill="#285e78" stroke="#123f57" stroke-width="2"/>
|
||||
<path d="M20 40c8-12 24-17 45-14 12 1 20 7 25 14-8 10-21 13-36 12-15-1-27-4-34-12Z" fill="url(#bird)" stroke="#123f57" stroke-width="2"/>
|
||||
<path d="M24 43c13 4 31 5 45 1 7-2 12-5 16-9 2 2 4 4 5 6-8 10-21 13-36 12-13-1-24-4-30-10Z" fill="#b7e5df"/>
|
||||
<path d="M67 27c7-8 18-9 24-2 4 4 4 10 1 16-8 1-16-3-23-9Z" fill="#377f98"/>
|
||||
<path d="M52 38C42 31 35 18 40 6c10 5 20 16 27 29-1 8-7 17-19 25 2-8 3-15 4-22Z" fill="#77b6c2" stroke="#123f57" stroke-width="2"/>
|
||||
<path d="M44 16c8 7 14 15 18 23M46 27c7 5 11 11 13 18M48 39c4 3 7 6 8 10" fill="none" stroke="#d4f0ed" stroke-width="1.5"/>
|
||||
<path d="M76 26c5-5 12-4 16 1-4 1-7 4-9 8-4-1-7-4-7-9Z" fill="#d8f0e9"/>
|
||||
<circle cx="86" cy="27" r="3.1" fill="#f5fbf7"/>
|
||||
<circle cx="86.7" cy="27" r="1.45" fill="#102d3d"/>
|
||||
<path d="m91 31 12 5-12 4c2-3 2-6 0-9Z" fill="#f2a84b" stroke="#6f4727" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -251,6 +251,10 @@ button {
|
||||
height: 844px;
|
||||
zoom: var(--phone-zoom, 1);
|
||||
}
|
||||
.phone-stage--landscape .phone-resolution-wrapper--primary {
|
||||
width: 844px;
|
||||
height: 390px;
|
||||
}
|
||||
.phone-device-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -282,6 +286,11 @@ button {
|
||||
box-shadow: none;
|
||||
filter: drop-shadow(0 40px 100px #0009);
|
||||
}
|
||||
.phone-stage--landscape .phone-resolution-wrapper--primary .phone-device {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
}
|
||||
.phone-device__frame {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
@@ -497,6 +506,18 @@ button {
|
||||
bottom: 25px;
|
||||
left: 0;
|
||||
}
|
||||
.lock-screen__shortcuts {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 48px;
|
||||
}
|
||||
.lock-screen__shortcut {
|
||||
--color-primary: transparent;
|
||||
}
|
||||
.lock-screen__shortcut svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
.lock-screen__swipe {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
@@ -1026,6 +1047,35 @@ button {
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
.app-switch-enter-active,
|
||||
.app-switch-leave-active {
|
||||
transition:
|
||||
transform 0.38s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
opacity 0.28s ease,
|
||||
border-radius 0.38s ease;
|
||||
}
|
||||
.app-switch-enter-active {
|
||||
z-index: 51;
|
||||
}
|
||||
.app-switch-leave-active {
|
||||
z-index: 50;
|
||||
}
|
||||
.app-switch-enter-from {
|
||||
transform: translateX(100%) scale(0.98);
|
||||
opacity: 0.75;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.app-switch-leave-to {
|
||||
transform: translateX(-22%) scale(0.96);
|
||||
opacity: 0.35;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.app-switch-enter-to,
|
||||
.app-switch-leave-from {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
.native-app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1076,7 +1126,12 @@ button {
|
||||
.weather-app--rain .weather-app__backdrop,
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
background:
|
||||
repeating-linear-gradient(104deg, transparent 0 22px, #bcecff17 23px 25px, transparent 26px 49px),
|
||||
repeating-linear-gradient(
|
||||
104deg,
|
||||
transparent 0 22px,
|
||||
#bcecff17 23px 25px,
|
||||
transparent 26px 49px
|
||||
),
|
||||
linear-gradient(165deg, #354b69 0%, #253952 46%, #101b2d 100%);
|
||||
}
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
@@ -1705,174 +1760,9 @@ button {
|
||||
.clock-timer {
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
.camera-app {
|
||||
padding: 45px 0 25px;
|
||||
background: #080808;
|
||||
}
|
||||
.camera-viewfinder {
|
||||
position: relative;
|
||||
height: 510px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 66% 25%, #ffc66c99, transparent 18%),
|
||||
linear-gradient(145deg, #193641, #443354 55%, #0c1b20);
|
||||
}
|
||||
.camera-facing--front {
|
||||
background:
|
||||
radial-gradient(circle at 48% 32%, #ffb58f99, transparent 16%),
|
||||
linear-gradient(145deg, #56394e, #172f48);
|
||||
}
|
||||
.camera-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
transparent 33%,
|
||||
#ffffff30 33.2%,
|
||||
transparent 33.5%,
|
||||
transparent 66%,
|
||||
#ffffff30 66.2%,
|
||||
transparent 66.5%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 33%,
|
||||
#ffffff30 33.2%,
|
||||
transparent 33.5%,
|
||||
transparent 66%,
|
||||
#ffffff30 66.2%,
|
||||
transparent 66.5%
|
||||
);
|
||||
}
|
||||
.camera-flash {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
background: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.camera-flash.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.camera-zoom {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.camera-zoom button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #0009;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
.camera-zoom button.active {
|
||||
color: #ffcc00;
|
||||
}
|
||||
.camera-modes {
|
||||
height: 35px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
}
|
||||
.camera-modes button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
}
|
||||
.camera-modes button.active {
|
||||
color: #ffcc00;
|
||||
}
|
||||
.camera-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.camera-controls > button {
|
||||
justify-self: center;
|
||||
width: 43px;
|
||||
height: 43px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #29292b;
|
||||
color: white;
|
||||
}
|
||||
.camera-controls .shutter {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
border: 4px solid white;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
box-shadow: inset 0 0 0 3px #000;
|
||||
}
|
||||
.photos-app,
|
||||
.store-app {
|
||||
background: #000;
|
||||
}
|
||||
.photo-grid {
|
||||
height: calc(100% - 115px);
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: 111px;
|
||||
gap: 2px;
|
||||
padding-bottom: 65px;
|
||||
}
|
||||
.photo-tile {
|
||||
min-height: 100px;
|
||||
}
|
||||
.album-cards {
|
||||
padding: 12px 17px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.album-cards article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.album-cards .photo-tile {
|
||||
height: 145px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.album-cards small {
|
||||
color: #888;
|
||||
}
|
||||
.album-cards .favorites {
|
||||
background: linear-gradient(145deg, #ff4777, #7f42db);
|
||||
}
|
||||
.featured-photo {
|
||||
padding: 12px 17px;
|
||||
}
|
||||
.featured-photo p {
|
||||
text-transform: uppercase;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.featured-photo div {
|
||||
height: 390px;
|
||||
border-radius: 19px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 18px;
|
||||
}
|
||||
.featured-photo span {
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 2px 8px #000;
|
||||
}
|
||||
.store-app {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1951,165 +1841,6 @@ button {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.reference-camera {
|
||||
display: grid;
|
||||
grid-template-rows: 83px minmax(0, 1fr) 190px;
|
||||
padding: 0 0 24px;
|
||||
background: #020202;
|
||||
}
|
||||
.camera-topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: end;
|
||||
padding: 45px 15px 11px;
|
||||
}
|
||||
.camera-topbar > div {
|
||||
display: flex;
|
||||
gap: 17px;
|
||||
}
|
||||
.camera-topbar > svg {
|
||||
justify-self: end;
|
||||
}
|
||||
.camera-topbar button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.camera-topbar .camera-chevron {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 20px;
|
||||
background: #262629;
|
||||
}
|
||||
.reference-viewfinder {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 66% 21%, #ffd88baa, transparent 15%),
|
||||
radial-gradient(circle at 45% 52%, #6d8ca5 0 6%, transparent 7%),
|
||||
linear-gradient(150deg, #173542, #5b3e5b 56%, #13191c);
|
||||
}
|
||||
.reference-viewfinder::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
transparent 45%,
|
||||
#ffffff0f 46%,
|
||||
transparent 48%
|
||||
);
|
||||
}
|
||||
.focus-corner {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-color: white;
|
||||
}
|
||||
.focus-corner--tl {
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
border-top: 1px solid;
|
||||
border-left: 1px solid;
|
||||
}
|
||||
.focus-corner--tr {
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
border-top: 1px solid;
|
||||
border-right: 1px solid;
|
||||
}
|
||||
.focus-corner--bl {
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
border-bottom: 1px solid;
|
||||
border-left: 1px solid;
|
||||
}
|
||||
.focus-corner--br {
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
border-right: 1px solid;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
.reference-viewfinder .camera-zoom {
|
||||
z-index: 3;
|
||||
bottom: 13px;
|
||||
}
|
||||
.reference-viewfinder .camera-zoom button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0008;
|
||||
font-weight: 700;
|
||||
}
|
||||
.reference-viewfinder .camera-zoom small {
|
||||
margin-left: 1px;
|
||||
font-size: 8px;
|
||||
}
|
||||
.reference-camera-footer {
|
||||
display: grid;
|
||||
grid-template-rows: 43px 1fr;
|
||||
background: #020202;
|
||||
}
|
||||
.camera-mode-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 21px;
|
||||
padding: 0 148px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.camera-mode-strip button {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.camera-mode-strip button.active {
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-shutter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
padding: 0 31px 12px;
|
||||
}
|
||||
.camera-thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.reference-shutter {
|
||||
justify-self: center;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #111;
|
||||
box-shadow: inset 0 0 0 3px #020202;
|
||||
}
|
||||
.reference-flip {
|
||||
justify-self: end;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #1c1c1e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reference-tabbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
@@ -2160,169 +1891,6 @@ button {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.reference-photos {
|
||||
padding: 0 0 79px;
|
||||
background: #020202;
|
||||
}
|
||||
.photos-library,
|
||||
.photos-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.photos-floating-header {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
height: 95px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 47px 17px 14px;
|
||||
background: linear-gradient(#000 55%, transparent);
|
||||
}
|
||||
.photos-floating-header > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.photos-floating-header strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
.photos-floating-header span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.photos-floating-header button {
|
||||
height: 27px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: #ffffff1d;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
}
|
||||
.photos-floating-header button:last-child {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.reference-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.reference-photo-grid article {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.photos-count {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
.photos-period {
|
||||
position: sticky;
|
||||
bottom: 5px;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border-radius: 22px;
|
||||
background: #303033aa;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
.photos-period button {
|
||||
padding: 5px 12px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.photos-period button.active {
|
||||
background: #ffffff25;
|
||||
color: white;
|
||||
}
|
||||
.photos-scroll {
|
||||
padding: 82px 16px 20px;
|
||||
}
|
||||
.photos-scroll > h1 {
|
||||
margin: 0 0 28px;
|
||||
font-size: 34px;
|
||||
}
|
||||
.photos-section-title {
|
||||
margin-top: 22px;
|
||||
padding-top: 13px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
border-top: 1px solid #ffffff1b;
|
||||
}
|
||||
.photos-section-title h2 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
.photos-section-title button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0a84ff;
|
||||
}
|
||||
.memory-card {
|
||||
position: relative;
|
||||
height: 410px;
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 17px;
|
||||
box-shadow: inset 0 -160px 100px #0008;
|
||||
}
|
||||
.memory-card > svg {
|
||||
margin-left: auto;
|
||||
}
|
||||
.memory-card > div {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.memory-card strong {
|
||||
font-size: 26px;
|
||||
}
|
||||
.memory-card span {
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.featured-row {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.featured-row article {
|
||||
width: 88%;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.featured-row article > div {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.featured-row article span {
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
}
|
||||
.reference-photos .photo-grid {
|
||||
height: auto;
|
||||
margin-top: 13px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.reference-store {
|
||||
padding: 0 0 79px;
|
||||
background: #0e0e0e;
|
||||
@@ -2476,13 +2044,6 @@ button {
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.camera-mode-strip {
|
||||
padding: 0;
|
||||
}
|
||||
.camera-mode-strip button:first-child {
|
||||
margin-left: -205px;
|
||||
}
|
||||
|
||||
.messages-page {
|
||||
background: #fff !important;
|
||||
color: #111;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
@@ -21,17 +22,22 @@ const props = withDefaults(
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const unreadCount = computed(() =>
|
||||
props.app.id === 'mail' ? mail.counts.unread : 0,
|
||||
)
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
if (props.app.id === 'citymarkt') return marketplace.counts.unread
|
||||
return 0
|
||||
})
|
||||
const notificationBadgeColors = {
|
||||
bg: 'bg-[#ff3b30]',
|
||||
text: 'text-white',
|
||||
}
|
||||
|
||||
function launch(event: MouseEvent): void {
|
||||
if (!props.app.route) return
|
||||
|
||||
const button = event.currentTarget as HTMLElement
|
||||
const screen = button.closest('.phone-screen')
|
||||
const icon = button.querySelector<HTMLElement>('.app-icon')
|
||||
@@ -64,6 +70,7 @@ function launch(event: MouseEvent): void {
|
||||
:class="{ 'app-icon-button--compact': compact }"
|
||||
type="button"
|
||||
:aria-label="phone.t(app.labelKey)"
|
||||
:aria-disabled="!app.route"
|
||||
@click="launch"
|
||||
>
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Redo2,
|
||||
Undo2,
|
||||
} from 'lucide-vue-next'
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
export type MailEditorLabels = {
|
||||
bold: string
|
||||
bulletList: string
|
||||
italic: string
|
||||
numberedList: string
|
||||
quote: string
|
||||
redo: string
|
||||
undo: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
editable?: boolean
|
||||
labels?: MailEditorLabels
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
editable: true,
|
||||
labels: () => ({
|
||||
bold: 'Bold',
|
||||
bulletList: 'Bullet list',
|
||||
italic: 'Italic',
|
||||
numberedList: 'Numbered list',
|
||||
quote: 'Quote',
|
||||
redo: 'Redo',
|
||||
undo: 'Undo',
|
||||
}),
|
||||
placeholder: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
function safeMarkdown(value: string): string {
|
||||
return String(
|
||||
DOMPurify.sanitize(value.replace(/\r\n?/g, '\n'), {
|
||||
ALLOWED_ATTR: [],
|
||||
ALLOWED_TAGS: [],
|
||||
KEEP_CONTENT: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
content: safeMarkdown(props.modelValue),
|
||||
contentType: 'markdown',
|
||||
editable: props.editable,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
heading: { levels: [2, 3] },
|
||||
horizontalRule: false,
|
||||
link: {
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
openOnClick: false,
|
||||
protocols: ['http', 'https'],
|
||||
},
|
||||
strike: false,
|
||||
underline: false,
|
||||
}),
|
||||
Markdown.configure({ markedOptions: { breaks: true, gfm: true } }),
|
||||
Placeholder.configure({ placeholder: props.placeholder }),
|
||||
],
|
||||
injectCSS: false,
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
emit('update:modelValue', currentEditor.getMarkdown())
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (!editor.value) return
|
||||
const safeValue = safeMarkdown(value)
|
||||
if (editor.value.getMarkdown() === safeValue) return
|
||||
editor.value.commands.setContent(safeValue, {
|
||||
contentType: 'markdown',
|
||||
emitUpdate: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.editable,
|
||||
(value) => editor.value?.setEditable(value),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mail-editor" :class="{ 'mail-editor--readonly': !editable }">
|
||||
<div v-if="editable && editor" class="mail-editor__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
:aria-label="labels.bold"
|
||||
:title="labels.bold"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<Bold :size="17" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
:aria-label="labels.italic"
|
||||
:title="labels.italic"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<Italic :size="17" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||
:aria-label="labels.bulletList"
|
||||
:title="labels.bulletList"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
<List :size="17" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||
:aria-label="labels.numberedList"
|
||||
:title="labels.numberedList"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
<ListOrdered :size="17" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||
:aria-label="labels.quote"
|
||||
:title="labels.quote"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
<Quote :size="17" />
|
||||
</button>
|
||||
<span class="mail-editor__toolbar-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!editor.can().chain().focus().undo().run()"
|
||||
:aria-label="labels.undo"
|
||||
:title="labels.undo"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
<Undo2 :size="17" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!editor.can().chain().focus().redo().run()"
|
||||
:aria-label="labels.redo"
|
||||
:title="labels.redo"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
<Redo2 :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="editor" :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-editor {
|
||||
min-height: 210px;
|
||||
color: #f5f5f7;
|
||||
}
|
||||
|
||||
.mail-editor__toolbar {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-height: 43px;
|
||||
padding: 5px 7px;
|
||||
border-bottom: 1px solid #ffffff14;
|
||||
background: #1c1c1ee8;
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
}
|
||||
|
||||
.mail-editor__toolbar button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #f5f5f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-editor__toolbar button.is-active {
|
||||
background: #0a84ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mail-editor__toolbar button:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mail-editor__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.tiptap) {
|
||||
min-height: 210px;
|
||||
padding: 15px 16px 92px;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mail-editor--readonly {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mail-editor--readonly :deep(.tiptap) {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
:deep(.tiptap p) {
|
||||
margin: 0 0 0.8em;
|
||||
}
|
||||
|
||||
:deep(.tiptap p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.tiptap h2),
|
||||
:deep(.tiptap h3) {
|
||||
margin: 1em 0 0.4em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
:deep(.tiptap ul),
|
||||
:deep(.tiptap ol) {
|
||||
margin: 0.55em 0 0.85em;
|
||||
padding-left: 1.45em;
|
||||
}
|
||||
|
||||
:deep(.tiptap blockquote) {
|
||||
margin: 0.85em 0;
|
||||
padding-left: 0.9em;
|
||||
border-left: 3px solid #5e5e63;
|
||||
color: #a1a1a6;
|
||||
}
|
||||
|
||||
:deep(.tiptap a) {
|
||||
color: #0a84ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:deep(.tiptap p.is-editor-empty:first-child::before) {
|
||||
float: left;
|
||||
height: 0;
|
||||
color: #8e8e93;
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,8 @@ function goHome(): void {
|
||||
:class="{ 'phone-home-indicator--interactive': isApp }"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.home')"
|
||||
@click="goHome"
|
||||
@pointerdown.stop="goHome"
|
||||
@click.stop="goHome"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { kLink, kNavbar } from 'konsta/vue'
|
||||
import { kFab } from 'konsta/vue'
|
||||
import {
|
||||
BatteryMedium,
|
||||
Camera,
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
unlock: []
|
||||
}>()
|
||||
|
||||
@@ -21,12 +23,11 @@ const now = ref(new Date())
|
||||
const dragOffset = ref(0)
|
||||
const dragging = ref(false)
|
||||
const flashlightActive = ref(false)
|
||||
const lockNavbarColors = { bgIos: 'bg-transparent' }
|
||||
const neutralGlassClass =
|
||||
'!bg-white/10 !shadow-none ring-1 ring-inset ring-white/15 backdrop-saturate-150'
|
||||
const activeFlashlightGlassClass =
|
||||
'!bg-white !shadow-none ring-1 ring-inset ring-white/60 backdrop-saturate-150'
|
||||
const whiteNavbarLinkColors = { navbarTextIos: 'text-white' }
|
||||
const shortcutColors = {
|
||||
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
||||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||||
textIos: 'text-black dark:text-white',
|
||||
}
|
||||
let pointerStart = 0
|
||||
let pointerStartedAt = 0
|
||||
let clockTicker: number | undefined
|
||||
@@ -52,12 +53,15 @@ const time = computed(() =>
|
||||
const dragStyle = computed(() => ({
|
||||
'--lock-drag': `${dragOffset.value}px`,
|
||||
}))
|
||||
const flashlightGlassClass = computed(() =>
|
||||
flashlightActive.value ? activeFlashlightGlassClass : neutralGlassClass,
|
||||
const flashlightShortcutColors = computed(() =>
|
||||
flashlightActive.value
|
||||
? {
|
||||
...shortcutColors,
|
||||
bgIos: 'bg-white',
|
||||
textIos: 'text-purple-500',
|
||||
}
|
||||
: shortcutColors,
|
||||
)
|
||||
const flashlightLinkColors = computed(() => ({
|
||||
navbarTextIos: flashlightActive.value ? 'text-purple-500' : 'text-white',
|
||||
}))
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if ((event.target as HTMLElement).closest('button')) return
|
||||
@@ -96,6 +100,13 @@ function unlockFromWallpaper(event: MouseEvent): void {
|
||||
emit('unlock')
|
||||
}
|
||||
|
||||
async function toggleFlashlight(): Promise<void> {
|
||||
const enabled = !flashlightActive.value
|
||||
flashlightActive.value = enabled
|
||||
const response = await nuiCall('camera:setFlash', { enabled })
|
||||
if (!response.success) flashlightActive.value = !enabled
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTicker = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
@@ -104,6 +115,8 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clockTicker !== undefined) window.clearInterval(clockTicker)
|
||||
if (flashlightActive.value)
|
||||
void nuiCall('camera:setFlash', { enabled: false })
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -146,37 +159,32 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="lock-screen__footer">
|
||||
<k-navbar
|
||||
transparent
|
||||
:colors="lockNavbarColors"
|
||||
inner-class="!px-12"
|
||||
:left-class="flashlightGlassClass"
|
||||
:right-class="neutralGlassClass"
|
||||
>
|
||||
<template #left>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:colors="flashlightLinkColors"
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('LockScreen.flashlight')"
|
||||
@click="flashlightActive = !flashlightActive"
|
||||
>
|
||||
<nav class="lock-screen__shortcuts">
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
class="lock-screen__shortcut"
|
||||
:colors="flashlightShortcutColors"
|
||||
:aria-label="phone.t('LockScreen.flashlight')"
|
||||
@click="toggleFlashlight"
|
||||
>
|
||||
<template #icon>
|
||||
<Flashlight :stroke-width="1.4" aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:colors="whiteNavbarLinkColors"
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('LockScreen.camera')"
|
||||
>
|
||||
</template>
|
||||
</k-fab>
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
class="lock-screen__shortcut"
|
||||
:colors="shortcutColors"
|
||||
:aria-label="phone.t('LockScreen.camera')"
|
||||
@click="emit('camera')"
|
||||
>
|
||||
<template #icon>
|
||||
<Camera :stroke-width="1.4" aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
</template>
|
||||
</k-fab>
|
||||
</nav>
|
||||
|
||||
<button class="lock-screen__swipe" type="button" @click="emit('unlock')">
|
||||
<span class="lock-screen__swipe-chevron" aria-hidden="true"></span>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import fixWebmDuration from 'fix-webm-duration'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type { UploadReady } from '@/types/media'
|
||||
import { createGameView, type GameView } from '@/utils/gameView'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
const captureFps = 30
|
||||
const maxCaptureEdge = 720
|
||||
const portraitAspect = 3 / 4
|
||||
const landscapeAspect = 16 / 9
|
||||
let bitrateBps = 1_500_000
|
||||
let landscape = false
|
||||
let zoom = 1
|
||||
let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let lastRenderAt = 0
|
||||
let recorder: MediaRecorder | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let chunks: RecordingChunk[] = []
|
||||
let lastChunkAt = 0
|
||||
let lastChunkTimecode: number | null = null
|
||||
let flushTimer: number | undefined
|
||||
|
||||
function captureDimensions(): { height: number; width: number } {
|
||||
return landscape
|
||||
? {
|
||||
height: Math.round(maxCaptureEdge / landscapeAspect),
|
||||
width: maxCaptureEdge,
|
||||
}
|
||||
: {
|
||||
height: maxCaptureEdge,
|
||||
width: Math.round(maxCaptureEdge * portraitAspect),
|
||||
}
|
||||
}
|
||||
|
||||
function postRecordState(active: boolean, saving = false): void {
|
||||
window.postMessage(
|
||||
{ data: { active, saving }, type: 'camera:recordState' },
|
||||
'*',
|
||||
)
|
||||
}
|
||||
|
||||
function ensureGameView(): GameView {
|
||||
if (!canvasRef.value) throw new Error('capture_failed')
|
||||
if (gameView && !gameView.isLost()) return gameView
|
||||
gameView?.dispose()
|
||||
const dimensions = captureDimensions()
|
||||
gameView = createGameView(canvasRef.value)
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
zoom,
|
||||
)
|
||||
return gameView
|
||||
}
|
||||
|
||||
function startRenderLoop(): void {
|
||||
const view = ensureGameView()
|
||||
if (renderFrameId !== undefined) return
|
||||
const render = (now: number) => {
|
||||
if (!gameView || gameView.isLost()) {
|
||||
renderFrameId = undefined
|
||||
return
|
||||
}
|
||||
renderFrameId = window.requestAnimationFrame(render)
|
||||
if (now - lastRenderAt < 1000 / captureFps) return
|
||||
lastRenderAt = now
|
||||
view.render()
|
||||
}
|
||||
lastRenderAt = 0
|
||||
renderFrameId = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
function stopRenderLoop(): void {
|
||||
if (renderFrameId !== undefined) {
|
||||
window.cancelAnimationFrame(renderFrameId)
|
||||
renderFrameId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resetRecording(): void {
|
||||
chunks = []
|
||||
lastChunkAt = 0
|
||||
lastChunkTimecode = null
|
||||
}
|
||||
|
||||
function stopTracks(): void {
|
||||
stream?.getTracks().forEach((track) => track.stop())
|
||||
stream = null
|
||||
}
|
||||
|
||||
function cleanupRecording(): void {
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop()
|
||||
recorder = null
|
||||
stopTracks()
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
stopRenderLoop()
|
||||
resetRecording()
|
||||
postRecordState(false)
|
||||
}
|
||||
|
||||
function startRecording(data: Record<string, unknown>): void {
|
||||
if (recorder) return
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'unsupported', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
const configuredBitrate = Number(data.bitrateKbps)
|
||||
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
|
||||
bitrateBps = Math.round(configuredBitrate * 1000)
|
||||
}
|
||||
startRenderLoop()
|
||||
resetRecording()
|
||||
stream = canvasRef.value?.captureStream(captureFps) ?? null
|
||||
if (!stream) {
|
||||
cleanupRecording()
|
||||
return
|
||||
}
|
||||
recorder = new MediaRecorder(stream, {
|
||||
mimeType: 'video/webm',
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (!event.data.size) return
|
||||
const now = Date.now()
|
||||
let durationMs = Math.max(0, now - lastChunkAt)
|
||||
if (typeof event.timecode === 'number') {
|
||||
durationMs =
|
||||
lastChunkTimecode === null
|
||||
? 0
|
||||
: Math.max(0, event.timecode - lastChunkTimecode)
|
||||
lastChunkTimecode = event.timecode
|
||||
}
|
||||
lastChunkAt = now
|
||||
chunks.push({ blob: event.data, durationMs })
|
||||
}
|
||||
recorder.start()
|
||||
flushTimer = window.setInterval(() => {
|
||||
if (recorder?.state === 'recording') recorder.requestData()
|
||||
}, 1000)
|
||||
postRecordState(true)
|
||||
}
|
||||
|
||||
async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const correlationId = String(data.correlationId ?? '')
|
||||
if (!recorder || recorder.state === 'inactive' || !correlationId) return
|
||||
postRecordState(false, true)
|
||||
recorder.requestData()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
recorder.stop()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
stopTracks()
|
||||
recorder = null
|
||||
stopRenderLoop()
|
||||
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
|
||||
let blob = new Blob(
|
||||
chunks.map((entry) => entry.blob),
|
||||
{ type: 'video/webm' },
|
||||
)
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
resetRecording()
|
||||
pendingVideos.set(correlationId, {
|
||||
blob,
|
||||
fileName: `camera-${correlationId}.webm`,
|
||||
})
|
||||
await nuiCall('media:requestUpload', {
|
||||
correlationId,
|
||||
mediaType: 'video',
|
||||
})
|
||||
}
|
||||
|
||||
async function renderFrames(view: GameView, count: number): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
view.render()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
const { height, width } = captureDimensions()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const view = createGameView(canvas, { preserveDrawingBuffer: true })
|
||||
try {
|
||||
view.resize(width, height, window.innerWidth, window.innerHeight, zoom)
|
||||
await renderFrames(view, 3)
|
||||
const output = document.createElement('canvas')
|
||||
output.width = width
|
||||
output.height = height
|
||||
const context = output.getContext('2d')
|
||||
if (!context) throw new Error('capture_failed')
|
||||
context.drawImage(canvas, 0, 0)
|
||||
const encoding = ready.photo?.Encoding ?? 'jpg'
|
||||
const mimeType =
|
||||
encoding === 'png'
|
||||
? 'image/png'
|
||||
: encoding === 'webp'
|
||||
? 'image/webp'
|
||||
: 'image/jpeg'
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
output.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('capture_failed'))),
|
||||
mimeType,
|
||||
ready.photo?.Quality ?? 0.95,
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
view.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
let blob: Blob
|
||||
let fileName: string
|
||||
try {
|
||||
if (ready.mediaType === 'video') {
|
||||
const pending = pendingVideos.get(ready.correlationId)
|
||||
if (!pending) throw new Error('capture_failed')
|
||||
pendingVideos.delete(ready.correlationId)
|
||||
blob = pending.blob
|
||||
fileName = pending.fileName
|
||||
} else {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
id?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error('upload_failed')
|
||||
}
|
||||
await nuiCall('media:completeUpload', {
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
const message = event.data as {
|
||||
data?: Record<string, unknown>
|
||||
type?: string
|
||||
}
|
||||
if (message.type === 'camera:recordStart') {
|
||||
startRecording(message.data ?? {})
|
||||
} else if (message.type === 'camera:recordStop') {
|
||||
void stopRecording(message.data ?? {})
|
||||
} else if (message.type === 'camera:recordCancel') {
|
||||
cleanupRecording()
|
||||
} else if (message.type === 'camera:orientation') {
|
||||
landscape = message.data?.landscape === true
|
||||
if (gameView && !gameView.isLost()) {
|
||||
const dimensions = captureDimensions()
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
zoom,
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'camera:zoom') {
|
||||
const nextZoom = Number(message.data?.zoom)
|
||||
if (![0.5, 1, 2, 3].includes(nextZoom)) return
|
||||
zoom = nextZoom
|
||||
if (gameView && !gameView.isLost()) {
|
||||
const dimensions = captureDimensions()
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
zoom,
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('message', onMessage))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
cleanupRecording()
|
||||
pendingVideos.clear()
|
||||
gameView?.dispose()
|
||||
gameView = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="phone-media-capture"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.phone-media-capture {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useCalendarStore } from '@/stores/calendar'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const calendar = useCalendarStore()
|
||||
const weather = useWeatherStore()
|
||||
const router = useRouter()
|
||||
const now = ref(new Date())
|
||||
@@ -57,15 +61,46 @@ const date = computed(() =>
|
||||
}).format(now.value),
|
||||
)
|
||||
const day = computed(() => now.value.getDate())
|
||||
const nextCalendarEvent = computed(() =>
|
||||
calendar.events
|
||||
.filter((event) => event.endsAt >= now.value.getTime())
|
||||
.sort((left, right) => left.startsAt - right.startsAt)
|
||||
.at(0),
|
||||
)
|
||||
const calendarEventLabel = computed(
|
||||
() =>
|
||||
nextCalendarEvent.value?.title ?? phone.t('Home.widgets.calendar.event'),
|
||||
)
|
||||
|
||||
async function loadCalendarDay(): Promise<void> {
|
||||
if (!account.email) {
|
||||
calendar.events = []
|
||||
return
|
||||
}
|
||||
|
||||
const start = new Date(now.value)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 1)
|
||||
await calendar.load(start.getTime(), end.getTime())
|
||||
}
|
||||
|
||||
function openWeather(): void {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/weather')
|
||||
}
|
||||
|
||||
function openCalendar(): void {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/calendar')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCalendarDay()
|
||||
intervalId = window.setInterval(() => {
|
||||
const previousDay = now.value.toDateString()
|
||||
now.value = new Date()
|
||||
if (now.value.toDateString() !== previousDay) void loadCalendarDay()
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -91,11 +126,16 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<div class="widget-row">
|
||||
<article class="widget widget--calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="widget widget--calendar"
|
||||
:aria-label="phone.t('Apps.calendar.name')"
|
||||
@click="openCalendar"
|
||||
>
|
||||
<span>{{ date }}</span>
|
||||
<strong>{{ day }}</strong>
|
||||
<small>{{ phone.t('Home.widgets.calendar.event') }}</small>
|
||||
</article>
|
||||
<small>{{ calendarEventLabel }}</small>
|
||||
</button>
|
||||
<article class="widget widget--battery">
|
||||
<BatteryCharging :size="25" aria-hidden="true" />
|
||||
<strong>78%</strong>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { MarketplaceImage } from '@/types/marketplace'
|
||||
|
||||
const props = defineProps<{
|
||||
emptyBody: string
|
||||
emptyTitle: string
|
||||
images: MarketplaceImage[]
|
||||
nextLabel: string
|
||||
photoLabel: string
|
||||
previousLabel: string
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
watch(
|
||||
() => props.images,
|
||||
() => (activeIndex.value = 0),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function move(direction: number): void {
|
||||
if (props.images.length < 2) return
|
||||
activeIndex.value =
|
||||
(activeIndex.value + direction + props.images.length) % props.images.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="citymarkt-gallery" :class="{ 'citymarkt-gallery--empty': !images.length }">
|
||||
<div
|
||||
v-if="images.length"
|
||||
class="citymarkt-gallery__image"
|
||||
:style="{ background: images[activeIndex]?.gradient }"
|
||||
role="img"
|
||||
:aria-label="`${photoLabel} ${activeIndex + 1}`"
|
||||
/>
|
||||
<div v-else class="citymarkt-gallery__empty">
|
||||
<span><ImageOff :size="27" /></span>
|
||||
<strong>{{ emptyTitle }}</strong>
|
||||
<small>{{ emptyBody }}</small>
|
||||
</div>
|
||||
|
||||
<template v-if="images.length > 1">
|
||||
<button
|
||||
class="citymarkt-gallery__arrow citymarkt-gallery__arrow--left"
|
||||
type="button"
|
||||
:aria-label="previousLabel"
|
||||
@click.stop="move(-1)"
|
||||
>
|
||||
<ChevronLeft :size="19" />
|
||||
</button>
|
||||
<button
|
||||
class="citymarkt-gallery__arrow citymarkt-gallery__arrow--right"
|
||||
type="button"
|
||||
:aria-label="nextLabel"
|
||||
@click.stop="move(1)"
|
||||
>
|
||||
<ChevronRight :size="19" />
|
||||
</button>
|
||||
<div class="citymarkt-gallery__dots" aria-hidden="true">
|
||||
<i
|
||||
v-for="(_, index) in images"
|
||||
:key="index"
|
||||
:class="{ active: index === activeIndex }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="images.length" class="citymarkt-gallery__count">
|
||||
{{ activeIndex + 1 }} / {{ images.length }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-gallery{position:relative;overflow:hidden;background:#252724}.citymarkt-gallery__image{position:absolute;inset:0;background-position:center!important;background-size:cover!important;transition:background .2s ease}.citymarkt-gallery__empty{position:absolute;inset:0;padding:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--muted)}.citymarkt-gallery__empty span{width:48px;height:48px;margin-bottom:8px;border:1px solid #ffffff12;border-radius:16px;display:grid;place-items:center;background:#ffffff08;color:var(--yellow)}.citymarkt-gallery__empty strong{font-size:12px}.citymarkt-gallery__empty small{max-width:190px;margin-top:3px;font-size:8px;line-height:1.35}.citymarkt-gallery__arrow{position:absolute;z-index:2;top:50%;width:31px;height:31px;padding:0;border:1px solid #ffffff24;border-radius:50%;display:grid;place-items:center;background:#11120fba;color:#fff;box-shadow:0 4px 13px #0005;transform:translateY(-50%)}.citymarkt-gallery__arrow--left{left:9px}.citymarkt-gallery__arrow--right{right:9px}.citymarkt-gallery__dots{position:absolute;z-index:2;right:52px;bottom:12px;left:52px;display:flex;justify-content:center;gap:4px}.citymarkt-gallery__dots i{width:4px;height:4px;border-radius:50%;background:#ffffff66;box-shadow:0 1px 3px #0008;transition:width .18s ease,background .18s ease}.citymarkt-gallery__dots i.active{width:11px;border-radius:4px;background:var(--yellow)}.citymarkt-gallery__count{position:absolute;z-index:2;right:9px;bottom:8px;padding:4px 7px;border-radius:8px;background:#11120fc7;color:#fff;font-size:8px;font-weight:800}:global(.citymarkt--light) .citymarkt-gallery--empty{background:#e9eae5}:global(.citymarkt--light) .citymarkt-gallery__empty span{border-color:#00000012;background:#00000008}
|
||||
.citymarkt-gallery__empty strong{font-size:14px}
|
||||
.citymarkt-gallery__empty small{margin-top:4px;font-size:11.5px;line-height:1.4}
|
||||
.citymarkt-gallery__count{font-size:10.5px}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { BadgeDollarSign, Check, RefreshCw, X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MarketplaceOffer } from '@/types/marketplace'
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: number
|
||||
actionable: boolean
|
||||
isCounter: boolean
|
||||
offer: MarketplaceOffer
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
accept: []
|
||||
counter: []
|
||||
reject: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const isOwn = computed(() => props.offer.proposer_account_id === props.accountId)
|
||||
const statusKey = computed(() =>
|
||||
props.offer.status === 'rejected' ? 'declined' : props.offer.status,
|
||||
)
|
||||
const formattedAmount = computed(() =>
|
||||
phone.t('Apps.citymarkt.money', {
|
||||
price: new Intl.NumberFormat(phone.lang, { maximumFractionDigits: 0 }).format(
|
||||
Number(props.offer.amount),
|
||||
),
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="citymarkt-offer"
|
||||
:class="[`citymarkt-offer--${offer.status}`, { 'citymarkt-offer--own': isOwn }]"
|
||||
>
|
||||
<header>
|
||||
<span><BadgeDollarSign :size="16" /></span>
|
||||
<div>
|
||||
<small>{{ phone.t(isCounter ? 'Apps.citymarkt.counterOffer' : 'Apps.citymarkt.offer') }}</small>
|
||||
<strong>{{ formattedAmount }}</strong>
|
||||
</div>
|
||||
<i>{{ phone.t(`Apps.citymarkt.offerStatus.${statusKey}`) }}</i>
|
||||
</header>
|
||||
<p>
|
||||
{{ phone.t(isOwn ? 'Apps.citymarkt.offeredByYou' : 'Apps.citymarkt.offeredToYou') }}
|
||||
</p>
|
||||
<div v-if="actionable" class="citymarkt-offer__actions">
|
||||
<button type="button" class="accept" @click="$emit('accept')">
|
||||
<Check :size="14" />{{ phone.t('Apps.citymarkt.acceptOffer') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('counter')">
|
||||
<RefreshCw :size="13" />{{ phone.t('Apps.citymarkt.negotiateOffer') }}
|
||||
</button>
|
||||
<button type="button" class="reject" @click="$emit('reject')">
|
||||
<X :size="14" />{{ phone.t('Apps.citymarkt.declineOffer') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
|
||||
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
type SelectOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SelectOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: string]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
|
||||
)
|
||||
|
||||
function open(): void {
|
||||
highlightedIndex.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((option) => option.value === props.modelValue),
|
||||
)
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function select(value: string): void {
|
||||
emit('change', value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + direction + props.options.length) % props.options.length
|
||||
}
|
||||
|
||||
function handleOutsidePointer(event: PointerEvent): void {
|
||||
if (!root.value?.contains(event.target as Node)) isOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
|
||||
<button
|
||||
class="citymarkt-select__trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen"
|
||||
@click="isOpen ? (isOpen = false) : open()"
|
||||
>
|
||||
<span>{{ selectedLabel }}</span>
|
||||
<ChevronDown :size="14" :class="{ open: isOpen }" />
|
||||
</button>
|
||||
|
||||
<Transition name="citymarkt-select">
|
||||
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
:class="{
|
||||
highlighted: index === highlightedIndex,
|
||||
selected: option.value === modelValue,
|
||||
}"
|
||||
@pointerenter="highlightedIndex = index"
|
||||
@click="select(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<Check v-if="option.value === modelValue" :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
|
||||
.citymarkt-select__trigger{height:40px;font-size:13px}
|
||||
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
|
||||
</style>
|
||||
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PHONE_APPS } from './apps'
|
||||
import { isPhoneAppId, PHONE_APPS } from './apps'
|
||||
describe('app registry', () => {
|
||||
it('has unique ids and routes with the reference dock order', () => {
|
||||
expect(new Set(PHONE_APPS.map((app) => app.id)).size).toBe(
|
||||
PHONE_APPS.length,
|
||||
)
|
||||
expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
PHONE_APPS.every(
|
||||
(app) => app.route === null || app.route === `/apps/${app.id}`,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.every(
|
||||
(app) => typeof app.iconImage === 'string' && app.iconImage.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({
|
||||
dockOrder: 0,
|
||||
labelKey: 'Apps.phone.name',
|
||||
@@ -26,10 +30,72 @@ describe('app registry', () => {
|
||||
labelKey: 'Apps.weather.name',
|
||||
route: '/apps/weather',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
|
||||
gridOrder: 21,
|
||||
labelKey: 'Apps.calendar.name',
|
||||
route: '/apps/calendar',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 12,
|
||||
labelKey: 'Apps.snake.name',
|
||||
route: '/apps/snake',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'memory')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 13,
|
||||
labelKey: 'Apps.memory.name',
|
||||
route: '/apps/memory',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'number-merge')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 14,
|
||||
labelKey: 'Apps.numberMerge.name',
|
||||
route: '/apps/number-merge',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'minesweeper')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 15,
|
||||
labelKey: 'Apps.minesweeper.name',
|
||||
route: '/apps/minesweeper',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'tower-stack')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 16,
|
||||
labelKey: 'Apps.towerStack.name',
|
||||
route: '/apps/tower-stack',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'sky-flappy')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 17,
|
||||
labelKey: 'Apps.skyFlappy.name',
|
||||
route: '/apps/sky-flappy',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'neon-drop')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 18,
|
||||
labelKey: 'Apps.neonDrop.name',
|
||||
route: '/apps/neon-drop',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'citymarkt')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 19,
|
||||
labelKey: 'Apps.citymarkt.name',
|
||||
route: '/apps/citymarkt',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'camera')).toMatchObject({
|
||||
route: '/apps/camera',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'photos')).toMatchObject({
|
||||
route: '/apps/photos',
|
||||
})
|
||||
expect(isPhoneAppId('camera')).toBe(true)
|
||||
expect(isPhoneAppId('photos')).toBe(true)
|
||||
expect(isPhoneAppId('clock')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id),
|
||||
).toEqual(['phone', 'messages', 'calculator'])
|
||||
).toEqual(['phone', 'messages', 'camera', 'clock'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Calculator,
|
||||
Bomb,
|
||||
Blocks,
|
||||
Camera,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Gamepad2,
|
||||
Grid2X2,
|
||||
Brain,
|
||||
Images,
|
||||
Layers3,
|
||||
Mail,
|
||||
MapPinned,
|
||||
MessageCircle,
|
||||
@@ -10,12 +18,17 @@ import {
|
||||
Settings,
|
||||
ShoppingBag,
|
||||
CloudSun,
|
||||
Wind,
|
||||
Tag,
|
||||
MapPinHouse,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
|
||||
import appStoreIcon from '@/assets/img/app-icons/apps.webp'
|
||||
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
|
||||
import cameraIcon from '@/assets/img/app-icons/camera.webp'
|
||||
import clockIcon from '@/assets/img/app-icons/clock.webp'
|
||||
import calendarIcon from '@/assets/img/app-icons/calendar.svg'
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import mapIcon from '@/assets/img/app-icons/map.webp'
|
||||
import messagesIcon from '@/assets/img/app-icons/sms.webp'
|
||||
@@ -23,10 +36,49 @@ import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
import settingsIcon from '@/assets/img/app-icons/settings.webp'
|
||||
import snakeIcon from '@/assets/img/app-icons/snake.webp'
|
||||
import memoryIcon from '@/assets/img/app-icons/memory.webp'
|
||||
import numberMergeIcon from '@/assets/img/app-icons/number-merge.webp'
|
||||
import minesweeperIcon from '@/assets/img/app-icons/minesweeper.webp'
|
||||
import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
|
||||
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
|
||||
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
|
||||
import weatherIcon from '@/assets/img/app-icons/weather.webp'
|
||||
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
|
||||
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
|
||||
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
PhoneAppDefinition,
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 21,
|
||||
icon: markRaw(CalendarDays),
|
||||
iconClass: 'app-icon--calendar',
|
||||
iconImage: calendarIcon,
|
||||
id: 'calendar',
|
||||
labelKey: 'Apps.calendar.name',
|
||||
route: '/apps/calendar',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 20,
|
||||
icon: markRaw(MapPinHouse),
|
||||
iconClass: 'app-icon--local-pages',
|
||||
iconImage: localPagesIcon,
|
||||
id: 'local-pages',
|
||||
labelKey: 'Apps.localPages.name',
|
||||
route: '/apps/local-pages',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
|
||||
@@ -96,7 +148,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
|
||||
),
|
||||
dockOrder: 2,
|
||||
dockOrder: null,
|
||||
gridOrder: 2,
|
||||
icon: markRaw(Calculator),
|
||||
iconClass: 'app-icon--calculator',
|
||||
@@ -105,11 +157,24 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
labelKey: 'Apps.calculator.name',
|
||||
route: '/apps/calculator',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
|
||||
),
|
||||
dockOrder: 2,
|
||||
gridOrder: 3,
|
||||
icon: markRaw(Camera),
|
||||
iconClass: 'app-icon--camera',
|
||||
iconImage: cameraIcon,
|
||||
id: 'camera',
|
||||
labelKey: 'Apps.camera.name',
|
||||
route: '/apps/camera',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
dockOrder: 3,
|
||||
gridOrder: 4,
|
||||
icon: markRaw(Clock3),
|
||||
iconClass: 'app-icon--clock',
|
||||
@@ -133,7 +198,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
|
||||
defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 8,
|
||||
@@ -170,6 +235,110 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
labelKey: 'Apps.settings.name',
|
||||
route: '/apps/settings',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 12,
|
||||
icon: markRaw(Gamepad2),
|
||||
iconClass: 'app-icon--snake',
|
||||
iconImage: snakeIcon,
|
||||
id: 'snake',
|
||||
labelKey: 'Apps.snake.name',
|
||||
route: '/apps/snake',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 13,
|
||||
icon: markRaw(Brain),
|
||||
iconClass: 'app-icon--memory',
|
||||
iconImage: memoryIcon,
|
||||
id: 'memory',
|
||||
labelKey: 'Apps.memory.name',
|
||||
route: '/apps/memory',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 14,
|
||||
icon: markRaw(Grid2X2),
|
||||
iconClass: 'app-icon--number-merge',
|
||||
iconImage: numberMergeIcon,
|
||||
id: 'number-merge',
|
||||
labelKey: 'Apps.numberMerge.name',
|
||||
route: '/apps/number-merge',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 15,
|
||||
icon: markRaw(Bomb),
|
||||
iconClass: 'app-icon--minesweeper',
|
||||
iconImage: minesweeperIcon,
|
||||
id: 'minesweeper',
|
||||
labelKey: 'Apps.minesweeper.name',
|
||||
route: '/apps/minesweeper',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 16,
|
||||
icon: markRaw(Layers3),
|
||||
iconClass: 'app-icon--tower-stack',
|
||||
iconImage: towerStackIcon,
|
||||
id: 'tower-stack',
|
||||
labelKey: 'Apps.towerStack.name',
|
||||
route: '/apps/tower-stack',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 17,
|
||||
icon: markRaw(Wind),
|
||||
iconClass: 'app-icon--sky-flappy',
|
||||
iconImage: skyFlappyIcon,
|
||||
id: 'sky-flappy',
|
||||
labelKey: 'Apps.skyFlappy.name',
|
||||
route: '/apps/sky-flappy',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 19,
|
||||
icon: markRaw(Tag),
|
||||
iconClass: 'app-icon--citymarkt',
|
||||
iconImage: citymarktIcon,
|
||||
id: 'citymarkt',
|
||||
labelKey: 'Apps.citymarkt.name',
|
||||
route: '/apps/citymarkt',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 18,
|
||||
icon: markRaw(Blocks),
|
||||
iconClass: 'app-icon--neon-drop',
|
||||
iconImage: neonDropIcon,
|
||||
id: 'neon-drop',
|
||||
labelKey: 'Apps.neonDrop.name',
|
||||
route: '/apps/neon-drop',
|
||||
},
|
||||
]
|
||||
|
||||
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
|
||||
@@ -181,6 +350,13 @@ export function getPhoneApp(
|
||||
return PHONE_APPS.find((app) => app.id === appId)
|
||||
}
|
||||
|
||||
export function isPhoneAppId(value: string): value is PhoneAppId {
|
||||
return PHONE_APP_IDS.includes(value as PhoneAppId)
|
||||
export function isPhoneAppId(value: string): value is LaunchablePhoneAppId {
|
||||
const app = getPhoneApp(value)
|
||||
return !!app && isLaunchablePhoneApp(app)
|
||||
}
|
||||
|
||||
export function isLaunchablePhoneApp(
|
||||
app: PhoneAppDefinition,
|
||||
): app is LaunchablePhoneAppDefinition {
|
||||
return app.component !== null && app.route !== null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
||||
|
||||
type Tone = {
|
||||
duration: number
|
||||
frequency: number
|
||||
offset: number
|
||||
type: OscillatorType
|
||||
volume: number
|
||||
}
|
||||
|
||||
const sounds: Record<MemorySound, Tone[]> = {
|
||||
flip: [
|
||||
{ duration: 0.055, frequency: 520, offset: 0, type: 'sine', volume: 0.07 },
|
||||
],
|
||||
match: [
|
||||
{ duration: 0.11, frequency: 660, offset: 0, type: 'sine', volume: 0.09 },
|
||||
{ duration: 0.16, frequency: 880, offset: 0.09, type: 'sine', volume: 0.1 },
|
||||
],
|
||||
mismatch: [
|
||||
{ duration: 0.1, frequency: 210, offset: 0, type: 'triangle', volume: 0.07 },
|
||||
{ duration: 0.14, frequency: 150, offset: 0.08, type: 'triangle', volume: 0.065 },
|
||||
],
|
||||
win: [
|
||||
{ duration: 0.16, frequency: 523.25, offset: 0, type: 'sine', volume: 0.085 },
|
||||
{ duration: 0.16, frequency: 659.25, offset: 0.1, type: 'sine', volume: 0.09 },
|
||||
{ duration: 0.16, frequency: 783.99, offset: 0.2, type: 'sine', volume: 0.095 },
|
||||
{ duration: 0.28, frequency: 1046.5, offset: 0.3, type: 'sine', volume: 0.1 },
|
||||
],
|
||||
}
|
||||
|
||||
let audioContext: AudioContext | undefined
|
||||
|
||||
export function playMemorySound(sound: MemorySound, enabled: boolean): void {
|
||||
if (!enabled) return
|
||||
|
||||
audioContext ??= new AudioContext()
|
||||
if (audioContext.state === 'suspended') void audioContext.resume()
|
||||
|
||||
const now = audioContext.currentTime
|
||||
for (const tone of sounds[sound]) {
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gain = audioContext.createGain()
|
||||
const start = now + tone.offset
|
||||
const end = start + tone.duration
|
||||
|
||||
oscillator.type = tone.type
|
||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(audioContext.destination)
|
||||
oscillator.start(start)
|
||||
oscillator.stop(end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createMemoryGame,
|
||||
flipMemoryCard,
|
||||
MEMORY_PAIR_COUNTS,
|
||||
resolveMemoryMismatch,
|
||||
} from './engine'
|
||||
|
||||
describe('Memory engine', () => {
|
||||
it.each(['small', 'medium', 'large'] as const)(
|
||||
'creates exactly two cards per symbol for %s',
|
||||
(difficulty) => {
|
||||
const game = createMemoryGame(difficulty, () => 0.5)
|
||||
const counts = game.cards.reduce<Record<string, number>>((result, card) => {
|
||||
result[card.symbol] = (result[card.symbol] ?? 0) + 1
|
||||
return result
|
||||
}, {})
|
||||
|
||||
expect(game.cards).toHaveLength(MEMORY_PAIR_COUNTS[difficulty] * 2)
|
||||
expect(Object.values(counts).every((count) => count === 2)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not count the same card twice', () => {
|
||||
const game = createMemoryGame('small', () => 0)
|
||||
const once = flipMemoryCard(game, game.cards[0].id)
|
||||
|
||||
expect(flipMemoryCard(once, game.cards[0].id)).toBe(once)
|
||||
expect(once.moves).toBe(0)
|
||||
})
|
||||
|
||||
it('matches equal cards and counts one move', () => {
|
||||
const game = createMemoryGame('small', () => 0)
|
||||
const pair = game.cards.filter((card) => card.symbol === game.cards[0].symbol)
|
||||
const first = flipMemoryCard(game, pair[0].id)
|
||||
const second = flipMemoryCard(first, pair[1].id)
|
||||
|
||||
expect(second.moves).toBe(1)
|
||||
expect(second.matchedPairs).toBe(1)
|
||||
expect(second.selectedIds).toEqual([])
|
||||
expect(second.cards.filter((card) => card.state === 'matched')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('blocks a third card until a mismatch is resolved', () => {
|
||||
const game = createMemoryGame('small', () => 0)
|
||||
const firstCard = game.cards[0]
|
||||
const secondCard = game.cards.find((card) => card.symbol !== firstCard.symbol)!
|
||||
const thirdCard = game.cards.find(
|
||||
(card) => card.symbol !== firstCard.symbol && card.id !== secondCard.id,
|
||||
)!
|
||||
const first = flipMemoryCard(game, firstCard.id)
|
||||
const mismatch = flipMemoryCard(first, secondCard.id)
|
||||
|
||||
expect(mismatch.status).toBe('resolving')
|
||||
expect(flipMemoryCard(mismatch, thirdCard.id)).toBe(mismatch)
|
||||
expect(resolveMemoryMismatch(mismatch).selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
it('marks the completed round as won exactly on the final pair', () => {
|
||||
let game = createMemoryGame('small', () => 0)
|
||||
const symbols = [...new Set(game.cards.map((card) => card.symbol))]
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const pair = game.cards.filter((card) => card.symbol === symbol)
|
||||
game = flipMemoryCard(game, pair[0].id)
|
||||
game = flipMemoryCard(game, pair[1].id)
|
||||
}
|
||||
|
||||
expect(game.status).toBe('won')
|
||||
expect(game.moves).toBe(MEMORY_PAIR_COUNTS.small)
|
||||
expect(flipMemoryCard(game, game.cards[0].id)).toBe(game)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
MemoryCard,
|
||||
MemoryDifficulty,
|
||||
MemoryGameState,
|
||||
} from './types'
|
||||
|
||||
export const MEMORY_PAIR_COUNTS: Record<MemoryDifficulty, number> = {
|
||||
small: 6,
|
||||
medium: 8,
|
||||
large: 10,
|
||||
}
|
||||
|
||||
const MEMORY_SYMBOLS = [
|
||||
'star',
|
||||
'heart',
|
||||
'moon',
|
||||
'sun',
|
||||
'cloud',
|
||||
'bolt',
|
||||
'diamond',
|
||||
'circle',
|
||||
'triangle',
|
||||
'flower',
|
||||
]
|
||||
|
||||
function shuffleCards(cards: MemoryCard[], random: () => number): MemoryCard[] {
|
||||
const shuffled = [...cards]
|
||||
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const target = Math.min(index, Math.floor(Math.max(0, random()) * (index + 1)))
|
||||
const current = shuffled[index]
|
||||
shuffled[index] = shuffled[target]
|
||||
shuffled[target] = current
|
||||
}
|
||||
|
||||
return shuffled
|
||||
}
|
||||
|
||||
export function createMemoryGame(
|
||||
difficulty: MemoryDifficulty,
|
||||
random: () => number = Math.random,
|
||||
): MemoryGameState {
|
||||
const symbols = MEMORY_SYMBOLS.slice(0, MEMORY_PAIR_COUNTS[difficulty])
|
||||
const cards = symbols.flatMap((symbol) => [
|
||||
{ id: `${symbol}-a`, state: 'hidden' as const, symbol },
|
||||
{ id: `${symbol}-b`, state: 'hidden' as const, symbol },
|
||||
])
|
||||
|
||||
return {
|
||||
cards: shuffleCards(cards, random),
|
||||
difficulty,
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
selectedIds: [],
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function flipMemoryCard(
|
||||
state: MemoryGameState,
|
||||
cardId: string,
|
||||
): MemoryGameState {
|
||||
if (state.status !== 'playing') return state
|
||||
|
||||
const selected = state.cards.find((card) => card.id === cardId)
|
||||
if (!selected || selected.state !== 'hidden') return state
|
||||
|
||||
const cards = state.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, state: 'revealed' as const } : card,
|
||||
)
|
||||
const selectedIds = [...state.selectedIds, cardId]
|
||||
if (selectedIds.length === 1) return { ...state, cards, selectedIds }
|
||||
|
||||
const first = cards.find((card) => card.id === selectedIds[0])
|
||||
const second = cards.find((card) => card.id === selectedIds[1])
|
||||
const moves = state.moves + 1
|
||||
|
||||
if (first?.symbol === second?.symbol) {
|
||||
const matchedCards = cards.map((card) =>
|
||||
selectedIds.includes(card.id)
|
||||
? { ...card, state: 'matched' as const }
|
||||
: card,
|
||||
)
|
||||
const matchedPairs = state.matchedPairs + 1
|
||||
return {
|
||||
...state,
|
||||
cards: matchedCards,
|
||||
matchedPairs,
|
||||
moves,
|
||||
selectedIds: [],
|
||||
status:
|
||||
matchedPairs === MEMORY_PAIR_COUNTS[state.difficulty]
|
||||
? 'won'
|
||||
: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, cards, moves, selectedIds, status: 'resolving' }
|
||||
}
|
||||
|
||||
export function resolveMemoryMismatch(state: MemoryGameState): MemoryGameState {
|
||||
if (state.status !== 'resolving') return state
|
||||
|
||||
return {
|
||||
...state,
|
||||
cards: state.cards.map((card) =>
|
||||
state.selectedIds.includes(card.id)
|
||||
? { ...card, state: 'hidden' as const }
|
||||
: card,
|
||||
),
|
||||
selectedIds: [],
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
createMemoryGame,
|
||||
flipMemoryCard,
|
||||
resolveMemoryMismatch,
|
||||
} from './engine'
|
||||
import type {
|
||||
MemoryBest,
|
||||
MemoryDifficulty,
|
||||
MemoryGameState,
|
||||
} from './types'
|
||||
|
||||
type MemorySave = {
|
||||
best: Partial<Record<MemoryDifficulty, MemoryBest>>
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function isMemoryBest(value: unknown): value is MemoryBest {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const best = value as Partial<MemoryBest>
|
||||
return (
|
||||
typeof best.moves === 'number' &&
|
||||
best.moves > 0 &&
|
||||
typeof best.timeMs === 'number' &&
|
||||
best.timeMs >= 0
|
||||
)
|
||||
}
|
||||
|
||||
export const useMemoryStore = defineStore('memory', {
|
||||
state: () => ({
|
||||
best: {} as Partial<Record<MemoryDifficulty, MemoryBest>>,
|
||||
elapsedMs: 0,
|
||||
game: null as MemoryGameState | null,
|
||||
hydrated: false,
|
||||
soundEnabled: true,
|
||||
startedAt: null as number | null,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<MemorySave>>('memory')
|
||||
for (const difficulty of ['small', 'medium', 'large'] as const) {
|
||||
const candidate = saved?.best?.[difficulty]
|
||||
if (isMemoryBest(candidate)) this.best[difficulty] = candidate
|
||||
}
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('memory', {
|
||||
best: this.best,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies MemorySave)
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
start(difficulty: MemoryDifficulty): void {
|
||||
this.game = createMemoryGame(difficulty)
|
||||
this.elapsedMs = 0
|
||||
this.startedAt = Date.now()
|
||||
},
|
||||
updateElapsed(now = Date.now()): void {
|
||||
if (this.startedAt === null) return
|
||||
this.elapsedMs += now - this.startedAt
|
||||
this.startedAt = now
|
||||
},
|
||||
pause(): void {
|
||||
this.updateElapsed()
|
||||
this.startedAt = null
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game?.status === 'playing' && this.startedAt === null) {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
},
|
||||
flip(cardId: string): void {
|
||||
if (!this.game) return
|
||||
|
||||
const previousStatus = this.game.status
|
||||
this.game = flipMemoryCard(this.game, cardId)
|
||||
if (previousStatus !== 'won' && this.game.status === 'won') {
|
||||
this.updateElapsed()
|
||||
this.startedAt = null
|
||||
const result = { moves: this.game.moves, timeMs: this.elapsedMs }
|
||||
const current = this.best[this.game.difficulty]
|
||||
if (
|
||||
!current ||
|
||||
result.moves < current.moves ||
|
||||
(result.moves === current.moves && result.timeMs < current.timeMs)
|
||||
) {
|
||||
this.best[this.game.difficulty] = result
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
},
|
||||
resolveMismatch(): void {
|
||||
if (this.game) this.game = resolveMemoryMismatch(this.game)
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.game = null
|
||||
this.elapsedMs = 0
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MemoryDifficulty = 'small' | 'medium' | 'large'
|
||||
|
||||
export type MemoryCardState = 'hidden' | 'revealed' | 'matched'
|
||||
|
||||
export type MemoryCard = {
|
||||
id: string
|
||||
state: MemoryCardState
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export type MemoryGameStatus = 'playing' | 'resolving' | 'won'
|
||||
|
||||
export type MemoryGameState = {
|
||||
cards: MemoryCard[]
|
||||
difficulty: MemoryDifficulty
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
selectedIds: string[]
|
||||
status: MemoryGameStatus
|
||||
}
|
||||
|
||||
export type MemoryBest = {
|
||||
moves: number
|
||||
timeMs: number
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
||||
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
||||
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
||||
import placeUrl from '@/assets/audio/minesweeper/place.wav?url'
|
||||
import revealUrl from '@/assets/audio/minesweeper/reveal.wav?url'
|
||||
import winUrl from '@/assets/audio/minesweeper/win.wav?url'
|
||||
|
||||
export type MinesweeperSound =
|
||||
| 'clear'
|
||||
| 'flag'
|
||||
| 'mine'
|
||||
| 'place'
|
||||
| 'reveal'
|
||||
| 'win'
|
||||
|
||||
const soundUrls: Record<MinesweeperSound, string> = {
|
||||
clear: clearUrl,
|
||||
flag: flagUrl,
|
||||
mine: mineUrl,
|
||||
place: placeUrl,
|
||||
reveal: revealUrl,
|
||||
win: winUrl,
|
||||
}
|
||||
const playerPools = new Map<MinesweeperSound, HTMLAudioElement[]>()
|
||||
|
||||
function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
|
||||
const existing = playerPools.get(sound)
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
})
|
||||
playerPools.set(sound, players)
|
||||
return players
|
||||
}
|
||||
|
||||
export function playMinesweeperSound(
|
||||
sound: MinesweeperSound,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (!enabled) return
|
||||
|
||||
const players = getPlayers(sound)
|
||||
const player = players.find((candidate) => candidate.paused) ?? players[0]
|
||||
player.currentTime = 0
|
||||
void player.play().catch((error: unknown) => {
|
||||
console.error(`[Minesweeper audio] Failed to play ${sound}`, error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createMinesweeperGame,
|
||||
isMinesweeperGameState,
|
||||
revealMinesweeperCell,
|
||||
toggleMinesweeperFlag,
|
||||
} from './engine'
|
||||
import type { MinesweeperGameState } from './types'
|
||||
|
||||
const chooseFirst = () => 0
|
||||
|
||||
function preparedGame(
|
||||
width: number,
|
||||
height: number,
|
||||
mines: number[],
|
||||
): MinesweeperGameState {
|
||||
const state = createMinesweeperGame('quick')
|
||||
const mineSet = new Set(mines)
|
||||
return {
|
||||
...state,
|
||||
cells: state.cells.map((cell) => ({
|
||||
...cell,
|
||||
adjacentMines: state.cells.filter(
|
||||
(candidate) =>
|
||||
mineSet.has(candidate.id) &&
|
||||
Math.abs(candidate.row - cell.row) <= 1 &&
|
||||
Math.abs(candidate.column - cell.column) <= 1 &&
|
||||
candidate.id !== cell.id,
|
||||
).length,
|
||||
isMine: mineSet.has(cell.id),
|
||||
})),
|
||||
height,
|
||||
mineCount: mines.length,
|
||||
status: 'playing',
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
describe('minesweeper engine', () => {
|
||||
it('creates the configured phone boards', () => {
|
||||
const game = createMinesweeperGame('classic')
|
||||
expect(game.width).toBe(7)
|
||||
expect(game.height).toBe(9)
|
||||
expect(game.mineCount).toBe(11)
|
||||
expect(game.cells).toHaveLength(63)
|
||||
})
|
||||
|
||||
it('places the exact mine count outside the protected first area', () => {
|
||||
const original = createMinesweeperGame('quick')
|
||||
const result = revealMinesweeperCell(original, 14, chooseFirst)
|
||||
const protectedIds = [7, 8, 9, 13, 14, 15, 19, 20, 21]
|
||||
expect(result.state.cells.filter((cell) => cell.isMine)).toHaveLength(7)
|
||||
expect(
|
||||
result.state.cells
|
||||
.filter((cell) => protectedIds.includes(cell.id))
|
||||
.every((cell) => !cell.isMine),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('calculates neighbour counts from the placed mines', () => {
|
||||
const result = revealMinesweeperCell(
|
||||
createMinesweeperGame('quick'),
|
||||
0,
|
||||
chooseFirst,
|
||||
)
|
||||
for (const cell of result.state.cells) {
|
||||
const actual = result.state.cells.filter(
|
||||
(candidate) =>
|
||||
candidate.isMine &&
|
||||
Math.abs(candidate.row - cell.row) <= 1 &&
|
||||
Math.abs(candidate.column - cell.column) <= 1 &&
|
||||
candidate.id !== cell.id,
|
||||
).length
|
||||
expect(cell.adjacentMines).toBe(actual)
|
||||
}
|
||||
})
|
||||
|
||||
it('reveals an empty area and its numbered boundary but no mine', () => {
|
||||
const game = preparedGame(6, 8, [0, 5, 42, 47, 43, 44, 45])
|
||||
const result = revealMinesweeperCell(game, 20)
|
||||
expect(result.revealedCount).toBeGreaterThan(1)
|
||||
expect(result.state.cells.some((cell) => cell.isMine && cell.isRevealed)).toBe(false)
|
||||
expect(
|
||||
result.state.cells.some(
|
||||
(cell) => cell.isRevealed && cell.adjacentMines > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not reveal a flagged cell', () => {
|
||||
const game = preparedGame(6, 8, [47])
|
||||
const flagged = toggleMinesweeperFlag(game, 10).state
|
||||
const result = revealMinesweeperCell(flagged, 10)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.state.cells[10].isRevealed).toBe(false)
|
||||
})
|
||||
|
||||
it('reveals all mines and records the exploded cell on loss', () => {
|
||||
const game = preparedGame(6, 8, [1, 47])
|
||||
const result = revealMinesweeperCell(game, 1)
|
||||
expect(result.state.status).toBe('lost')
|
||||
expect(result.state.explodedCellId).toBe(1)
|
||||
expect(result.state.cells.filter((cell) => cell.isMine).every((cell) => cell.isRevealed)).toBe(true)
|
||||
})
|
||||
|
||||
it('wins when every safe field is revealed', () => {
|
||||
let game = preparedGame(6, 8, [47])
|
||||
for (const cell of game.cells) {
|
||||
if (!cell.isMine && !game.cells[cell.id].isRevealed) {
|
||||
game = revealMinesweeperCell(game, cell.id).state
|
||||
}
|
||||
}
|
||||
expect(game.status).toBe('won')
|
||||
expect(game.cells[47].isFlagged).toBe(true)
|
||||
})
|
||||
|
||||
it('validates persisted games', () => {
|
||||
const game = createMinesweeperGame('expert')
|
||||
expect(isMinesweeperGameState(game)).toBe(true)
|
||||
expect(isMinesweeperGameState({ ...game, mineCount: 99 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
MinesweeperActionResult,
|
||||
MinesweeperCell,
|
||||
MinesweeperDifficulty,
|
||||
MinesweeperGameState,
|
||||
} from './types'
|
||||
|
||||
export const MINESWEEPER_DIFFICULTIES: Record<
|
||||
MinesweeperDifficulty,
|
||||
{ height: number; mines: number; width: number }
|
||||
> = {
|
||||
quick: { height: 8, mines: 7, width: 6 },
|
||||
classic: { height: 9, mines: 11, width: 7 },
|
||||
expert: { height: 10, mines: 16, width: 8 },
|
||||
}
|
||||
|
||||
function neighbours(
|
||||
cell: MinesweeperCell,
|
||||
width: number,
|
||||
height: number,
|
||||
): number[] {
|
||||
const ids: number[] = []
|
||||
for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) {
|
||||
for (let columnOffset = -1; columnOffset <= 1; columnOffset += 1) {
|
||||
if (rowOffset === 0 && columnOffset === 0) continue
|
||||
const row = cell.row + rowOffset
|
||||
const column = cell.column + columnOffset
|
||||
if (row >= 0 && row < height && column >= 0 && column < width) {
|
||||
ids.push(row * width + column)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function placeMines(
|
||||
state: MinesweeperGameState,
|
||||
firstCellId: number,
|
||||
random: () => number,
|
||||
): MinesweeperGameState {
|
||||
const firstCell = state.cells[firstCellId]
|
||||
const protectedIds = new Set([
|
||||
firstCellId,
|
||||
...neighbours(firstCell, state.width, state.height),
|
||||
])
|
||||
const candidates = state.cells
|
||||
.map((cell) => cell.id)
|
||||
.filter((id) => !protectedIds.has(id))
|
||||
const mineIds = new Set<number>()
|
||||
|
||||
for (let count = 0; count < state.mineCount; count += 1) {
|
||||
const candidateIndex = Math.min(
|
||||
candidates.length - 1,
|
||||
Math.floor(random() * candidates.length),
|
||||
)
|
||||
const [mineId] = candidates.splice(candidateIndex, 1)
|
||||
mineIds.add(mineId)
|
||||
}
|
||||
|
||||
const cells = state.cells.map((cell) => ({
|
||||
...cell,
|
||||
isMine: mineIds.has(cell.id),
|
||||
}))
|
||||
return {
|
||||
...state,
|
||||
cells: cells.map((cell) => ({
|
||||
...cell,
|
||||
adjacentMines: neighbours(cell, state.width, state.height).filter(
|
||||
(id) => mineIds.has(id),
|
||||
).length,
|
||||
})),
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function createMinesweeperGame(
|
||||
difficulty: MinesweeperDifficulty,
|
||||
): MinesweeperGameState {
|
||||
const config = MINESWEEPER_DIFFICULTIES[difficulty]
|
||||
return {
|
||||
cells: Array.from({ length: config.width * config.height }, (_, id) => ({
|
||||
adjacentMines: 0,
|
||||
column: id % config.width,
|
||||
id,
|
||||
isFlagged: false,
|
||||
isMine: false,
|
||||
isRevealed: false,
|
||||
row: Math.floor(id / config.width),
|
||||
})),
|
||||
difficulty,
|
||||
explodedCellId: null,
|
||||
height: config.height,
|
||||
mineCount: config.mines,
|
||||
status: 'ready',
|
||||
width: config.width,
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMinesweeperFlag(
|
||||
state: MinesweeperGameState,
|
||||
cellId: number,
|
||||
): MinesweeperActionResult {
|
||||
if (state.status === 'lost' || state.status === 'won') {
|
||||
return { changed: false, revealedCount: 0, state }
|
||||
}
|
||||
|
||||
const cell = state.cells[cellId]
|
||||
if (!cell || cell.isRevealed) {
|
||||
return { changed: false, revealedCount: 0, state }
|
||||
}
|
||||
|
||||
const flagCount = state.cells.filter((candidate) => candidate.isFlagged).length
|
||||
if (!cell.isFlagged && flagCount >= state.mineCount) {
|
||||
return { changed: false, revealedCount: 0, state }
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
revealedCount: 0,
|
||||
state: {
|
||||
...state,
|
||||
cells: state.cells.map((candidate) =>
|
||||
candidate.id === cellId
|
||||
? { ...candidate, isFlagged: !candidate.isFlagged }
|
||||
: candidate,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function revealMinesweeperCell(
|
||||
originalState: MinesweeperGameState,
|
||||
cellId: number,
|
||||
random: () => number = Math.random,
|
||||
): MinesweeperActionResult {
|
||||
if (originalState.status === 'lost' || originalState.status === 'won') {
|
||||
return { changed: false, revealedCount: 0, state: originalState }
|
||||
}
|
||||
|
||||
const originalCell = originalState.cells[cellId]
|
||||
if (!originalCell || originalCell.isFlagged || originalCell.isRevealed) {
|
||||
return { changed: false, revealedCount: 0, state: originalState }
|
||||
}
|
||||
|
||||
const state =
|
||||
originalState.status === 'ready'
|
||||
? placeMines(originalState, cellId, random)
|
||||
: originalState
|
||||
const selectedCell = state.cells[cellId]
|
||||
|
||||
if (selectedCell.isMine) {
|
||||
return {
|
||||
changed: true,
|
||||
revealedCount: 0,
|
||||
state: {
|
||||
...state,
|
||||
cells: state.cells.map((cell) =>
|
||||
cell.isMine ? { ...cell, isRevealed: true } : cell,
|
||||
),
|
||||
explodedCellId: cellId,
|
||||
status: 'lost',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const cells = state.cells.map((cell) => ({ ...cell }))
|
||||
const queue = [cellId]
|
||||
const visited = new Set<number>()
|
||||
let revealedCount = 0
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift() as number
|
||||
if (visited.has(currentId)) continue
|
||||
visited.add(currentId)
|
||||
|
||||
const cell = cells[currentId]
|
||||
if (cell.isFlagged || cell.isMine || cell.isRevealed) continue
|
||||
cell.isRevealed = true
|
||||
revealedCount += 1
|
||||
|
||||
if (cell.adjacentMines === 0) {
|
||||
for (const neighbourId of neighbours(cell, state.width, state.height)) {
|
||||
if (!visited.has(neighbourId) && !cells[neighbourId].isMine) {
|
||||
queue.push(neighbourId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safeCellCount = cells.length - state.mineCount
|
||||
const totalRevealed = cells.filter(
|
||||
(cell) => cell.isRevealed && !cell.isMine,
|
||||
).length
|
||||
const won = totalRevealed === safeCellCount
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
revealedCount,
|
||||
state: {
|
||||
...state,
|
||||
cells: won
|
||||
? cells.map((cell) =>
|
||||
cell.isMine ? { ...cell, isFlagged: true } : cell,
|
||||
)
|
||||
: cells,
|
||||
status: won ? 'won' : 'playing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isMinesweeperGameState(
|
||||
value: unknown,
|
||||
): value is MinesweeperGameState {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const game = value as Partial<MinesweeperGameState>
|
||||
if (
|
||||
!['quick', 'classic', 'expert'].includes(game.difficulty ?? '') ||
|
||||
!['ready', 'playing', 'won', 'lost'].includes(game.status ?? '') ||
|
||||
!Number.isInteger(game.width) ||
|
||||
!Number.isInteger(game.height) ||
|
||||
!Number.isInteger(game.mineCount) ||
|
||||
!Array.isArray(game.cells) ||
|
||||
game.cells.length !== (game.width ?? 0) * (game.height ?? 0) ||
|
||||
(game.explodedCellId !== null && !Number.isInteger(game.explodedCellId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const config = MINESWEEPER_DIFFICULTIES[game.difficulty as MinesweeperDifficulty]
|
||||
if (
|
||||
game.width !== config.width ||
|
||||
game.height !== config.height ||
|
||||
game.mineCount !== config.mines
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let id = 0; id < game.cells.length; id += 1) {
|
||||
const cell = game.cells[id] as Partial<MinesweeperCell>
|
||||
if (
|
||||
!cell ||
|
||||
cell.id !== id ||
|
||||
cell.row !== Math.floor(id / config.width) ||
|
||||
cell.column !== id % config.width ||
|
||||
!Number.isInteger(cell.adjacentMines) ||
|
||||
(cell.adjacentMines ?? -1) < 0 ||
|
||||
(cell.adjacentMines ?? 9) > 8 ||
|
||||
typeof cell.isFlagged !== 'boolean' ||
|
||||
typeof cell.isMine !== 'boolean' ||
|
||||
typeof cell.isRevealed !== 'boolean'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const placedMineCount = game.cells.filter((cell) => cell.isMine).length
|
||||
return game.status === 'ready'
|
||||
? placedMineCount === 0
|
||||
: placedMineCount === game.mineCount
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
|
||||
import {
|
||||
createMinesweeperGame,
|
||||
isMinesweeperGameState,
|
||||
revealMinesweeperCell,
|
||||
toggleMinesweeperFlag,
|
||||
} from './engine'
|
||||
import type {
|
||||
MinesweeperActionResult,
|
||||
MinesweeperBest,
|
||||
MinesweeperDifficulty,
|
||||
MinesweeperGameState,
|
||||
} from './types'
|
||||
|
||||
type MinesweeperSave = {
|
||||
best: Partial<Record<MinesweeperDifficulty, MinesweeperBest>>
|
||||
elapsedMs: number
|
||||
game: MinesweeperGameState | null
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function isBest(value: unknown): value is MinesweeperBest {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as Partial<MinesweeperBest>).timeMs === 'number' &&
|
||||
((value as Partial<MinesweeperBest>).timeMs ?? -1) >= 0
|
||||
)
|
||||
}
|
||||
|
||||
export const useMinesweeperStore = defineStore('minesweeper', {
|
||||
state: () => ({
|
||||
best: {} as Partial<Record<MinesweeperDifficulty, MinesweeperBest>>,
|
||||
elapsedMs: 0,
|
||||
game: null as MinesweeperGameState | null,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
startedAt: null as number | null,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<MinesweeperSave>>(
|
||||
'minesweeper',
|
||||
)
|
||||
for (const difficulty of ['quick', 'classic', 'expert'] as const) {
|
||||
if (isBest(saved?.best?.[difficulty])) {
|
||||
this.best[difficulty] = saved.best[difficulty]
|
||||
}
|
||||
}
|
||||
this.elapsedMs =
|
||||
typeof saved?.elapsedMs === 'number' && saved.elapsedMs >= 0
|
||||
? saved.elapsedMs
|
||||
: 0
|
||||
this.game = isMinesweeperGameState(saved?.game)
|
||||
? cloneJsonData(saved.game)
|
||||
: null
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('minesweeper', {
|
||||
best: this.best,
|
||||
elapsedMs: this.elapsedMs,
|
||||
game: this.game,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies MinesweeperSave)
|
||||
},
|
||||
start(difficulty: MinesweeperDifficulty): void {
|
||||
this.game = createMinesweeperGame(difficulty)
|
||||
this.elapsedMs = 0
|
||||
this.menuOpen = false
|
||||
this.startedAt = null
|
||||
this.persist()
|
||||
},
|
||||
resumeGame(): void {
|
||||
if (!this.game) return
|
||||
this.menuOpen = false
|
||||
if (this.game.status === 'playing') this.startedAt = Date.now()
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.menuOpen = true
|
||||
this.persist()
|
||||
},
|
||||
updateElapsed(now = Date.now()): void {
|
||||
if (this.startedAt === null) return
|
||||
this.elapsedMs += now - this.startedAt
|
||||
this.startedAt = now
|
||||
},
|
||||
pause(): void {
|
||||
this.updateElapsed()
|
||||
this.startedAt = null
|
||||
},
|
||||
reveal(cellId: number): MinesweeperActionResult | null {
|
||||
if (!this.game) return null
|
||||
|
||||
const previousStatus = this.game.status
|
||||
const actionStartedAt = Date.now()
|
||||
const result = revealMinesweeperCell(this.game, cellId)
|
||||
if (!result.changed) return result
|
||||
|
||||
this.game = result.state
|
||||
if (previousStatus === 'ready') {
|
||||
this.startedAt = actionStartedAt
|
||||
}
|
||||
if (result.state.status === 'won' || result.state.status === 'lost') {
|
||||
this.pause()
|
||||
if (result.state.status === 'won') {
|
||||
const current = this.best[result.state.difficulty]
|
||||
if (!current || this.elapsedMs < current.timeMs) {
|
||||
this.best[result.state.difficulty] = { timeMs: this.elapsedMs }
|
||||
}
|
||||
}
|
||||
}
|
||||
this.persist()
|
||||
return result
|
||||
},
|
||||
toggleFlag(cellId: number): MinesweeperActionResult | null {
|
||||
if (!this.game) return null
|
||||
const result = toggleMinesweeperFlag(this.game, cellId)
|
||||
if (result.changed) {
|
||||
this.game = result.state
|
||||
this.persist()
|
||||
}
|
||||
return result
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
export type MinesweeperDifficulty = 'classic' | 'expert' | 'quick'
|
||||
export type MinesweeperStatus = 'lost' | 'playing' | 'ready' | 'won'
|
||||
|
||||
export type MinesweeperCell = {
|
||||
adjacentMines: number
|
||||
column: number
|
||||
id: number
|
||||
isFlagged: boolean
|
||||
isMine: boolean
|
||||
isRevealed: boolean
|
||||
row: number
|
||||
}
|
||||
|
||||
export type MinesweeperGameState = {
|
||||
cells: MinesweeperCell[]
|
||||
difficulty: MinesweeperDifficulty
|
||||
explodedCellId: number | null
|
||||
height: number
|
||||
mineCount: number
|
||||
status: MinesweeperStatus
|
||||
width: number
|
||||
}
|
||||
|
||||
export type MinesweeperBest = {
|
||||
timeMs: number
|
||||
}
|
||||
|
||||
export type MinesweeperActionResult = {
|
||||
changed: boolean
|
||||
revealedCount: number
|
||||
state: MinesweeperGameState
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type NeonDropSound =
|
||||
| 'clear'
|
||||
| 'drop'
|
||||
| 'game-over'
|
||||
| 'lock'
|
||||
| 'move'
|
||||
| 'rotate'
|
||||
| 'start'
|
||||
|
||||
let context: AudioContext | undefined
|
||||
|
||||
function playSequence(sound: NeonDropSound): void {
|
||||
context ??= new AudioContext()
|
||||
const patterns: Record<
|
||||
NeonDropSound,
|
||||
Array<[number, number, number, OscillatorType]>
|
||||
> = {
|
||||
clear: [
|
||||
[520, 0, 0.08, 'sine'],
|
||||
[700, 0.07, 0.1, 'sine'],
|
||||
[930, 0.15, 0.16, 'triangle'],
|
||||
],
|
||||
drop: [
|
||||
[180, 0, 0.07, 'square'],
|
||||
[95, 0.055, 0.11, 'triangle'],
|
||||
],
|
||||
'game-over': [
|
||||
[260, 0, 0.16, 'sawtooth'],
|
||||
[190, 0.14, 0.18, 'sawtooth'],
|
||||
[110, 0.3, 0.28, 'triangle'],
|
||||
],
|
||||
lock: [[145, 0, 0.07, 'triangle']],
|
||||
move: [[340, 0, 0.035, 'square']],
|
||||
rotate: [
|
||||
[410, 0, 0.045, 'triangle'],
|
||||
[530, 0.035, 0.055, 'triangle'],
|
||||
],
|
||||
start: [
|
||||
[330, 0, 0.08, 'sine'],
|
||||
[500, 0.07, 0.1, 'triangle'],
|
||||
[760, 0.15, 0.13, 'sine'],
|
||||
],
|
||||
}
|
||||
const start = context.currentTime
|
||||
for (const [frequency, delay, duration, type] of patterns[sound]) {
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
oscillator.type = type
|
||||
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
||||
gain.gain.setValueAtTime(0.0001, start + delay)
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
oscillator.start(start + delay)
|
||||
oscillator.stop(start + delay + duration + 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
export function playNeonDropSound(
|
||||
sound: NeonDropSound,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (!enabled) return
|
||||
context ??= new AudioContext()
|
||||
if (context.state === 'suspended') {
|
||||
void context
|
||||
.resume()
|
||||
.then(() => playSequence(sound))
|
||||
.catch((error: unknown) => {
|
||||
console.error(`[Neon Drop audio] Failed to resume for ${sound}`, error)
|
||||
})
|
||||
return
|
||||
}
|
||||
playSequence(sound)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
canPlaceNeonDropPiece,
|
||||
clearCompletedNeonDropLines,
|
||||
createNeonDropBoard,
|
||||
createNeonDropGame,
|
||||
getNeonDropCells,
|
||||
getNeonDropGhostPiece,
|
||||
getNeonDropInterval,
|
||||
hardDropNeonPiece,
|
||||
moveNeonDropPiece,
|
||||
NEON_DROP_COLUMNS,
|
||||
NEON_DROP_ROWS,
|
||||
rotateNeonDropPiece,
|
||||
stepNeonDrop,
|
||||
} from './engine'
|
||||
|
||||
describe('neon drop engine', () => {
|
||||
it('starts with a valid piece and a complete seven-piece bag', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
expect(game.status).toBe('playing')
|
||||
expect(new Set([game.active.kind, ...game.queue]).size).toBe(7)
|
||||
expect(canPlaceNeonDropPiece(game.board, game.active)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not move through the side walls', () => {
|
||||
let game = createNeonDropGame(() => 0)
|
||||
for (let index = 0; index < NEON_DROP_COLUMNS; index += 1) {
|
||||
game = moveNeonDropPiece(game, -1).state
|
||||
}
|
||||
expect(
|
||||
Math.min(...getNeonDropCells(game.active).map((cell) => cell.x)),
|
||||
).toBe(0)
|
||||
expect(moveNeonDropPiece(game, -1).event).toBe('none')
|
||||
})
|
||||
|
||||
it('rotates pieces while keeping every cell inside the board', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
const rotated = rotateNeonDropPiece(game)
|
||||
expect(rotated.event).toBe('rotate')
|
||||
expect(
|
||||
canPlaceNeonDropPiece(rotated.state.board, rotated.state.active),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears complete rows and moves remaining cells downward', () => {
|
||||
const board = createNeonDropBoard()
|
||||
board[NEON_DROP_ROWS - 1].fill('I')
|
||||
board[NEON_DROP_ROWS - 2][0] = 'T'
|
||||
const result = clearCompletedNeonDropLines(board)
|
||||
expect(result.clearedLines).toBe(1)
|
||||
expect(result.board[NEON_DROP_ROWS - 1][0]).toBe('T')
|
||||
expect(result.board[0].every((cell) => cell === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('hard drops exactly onto the ghost position and awards distance points', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
const ghost = getNeonDropGhostPiece(game)
|
||||
const distance = ghost.y - game.active.y
|
||||
const result = hardDropNeonPiece(game, () => 0.5)
|
||||
expect(result.event).toBe('lock')
|
||||
expect(result.state.score).toBe(distance * 2)
|
||||
expect(result.state.board.flat().filter(Boolean)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('awards line points and advances the level after eight cleared rows', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
const board = createNeonDropBoard()
|
||||
board[NEON_DROP_ROWS - 1].fill('J')
|
||||
for (let x = 3; x <= 6; x += 1) board[NEON_DROP_ROWS - 1][x] = null
|
||||
const result = hardDropNeonPiece({
|
||||
...game,
|
||||
active: { kind: 'I', rotation: 0, x: 3, y: NEON_DROP_ROWS - 2 },
|
||||
board,
|
||||
lines: 7,
|
||||
})
|
||||
expect(result.clearedLines).toBe(1)
|
||||
expect(result.state.score).toBe(100)
|
||||
expect(result.state.lines).toBe(8)
|
||||
expect(result.state.level).toBe(2)
|
||||
})
|
||||
|
||||
it('ends the round when the next piece cannot enter the board', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
const board = createNeonDropBoard()
|
||||
board[2].fill('Z')
|
||||
const result = stepNeonDrop({
|
||||
...game,
|
||||
active: { kind: 'O', rotation: 0, x: 3, y: 0 },
|
||||
board,
|
||||
})
|
||||
expect(result.event).toBe('game-over')
|
||||
expect(result.state.status).toBe('over')
|
||||
})
|
||||
|
||||
it('increases speed each level but keeps a playable minimum interval', () => {
|
||||
expect(getNeonDropInterval(2)).toBeLessThan(getNeonDropInterval(1))
|
||||
expect(getNeonDropInterval(100)).toBe(110)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
import type {
|
||||
NeonDropActionResult,
|
||||
NeonDropCell,
|
||||
NeonDropGameState,
|
||||
NeonDropPiece,
|
||||
NeonDropPieceKind,
|
||||
NeonDropPoint,
|
||||
} from './types'
|
||||
|
||||
export const NEON_DROP_COLUMNS = 10
|
||||
export const NEON_DROP_ROWS = 18
|
||||
export const NEON_DROP_LINES_PER_LEVEL = 8
|
||||
|
||||
const PIECE_KINDS: NeonDropPieceKind[] = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']
|
||||
const LINE_SCORES = [0, 100, 300, 500, 800]
|
||||
const SHAPES: Record<NeonDropPieceKind, NeonDropPoint[][]> = {
|
||||
I: [
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 2, y: 3 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 3, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
],
|
||||
J: [
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 2, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
],
|
||||
L: [
|
||||
[
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 2, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 0, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
],
|
||||
O: [
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
],
|
||||
S: [
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 2, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
],
|
||||
T: [
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
],
|
||||
Z: [
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 2, y: 2 },
|
||||
],
|
||||
[
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 2 },
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
export function createNeonDropBoard(): NeonDropCell[][] {
|
||||
return Array.from({ length: NEON_DROP_ROWS }, () =>
|
||||
Array<NeonDropCell>(NEON_DROP_COLUMNS).fill(null),
|
||||
)
|
||||
}
|
||||
|
||||
function shuffleBag(random: () => number): NeonDropPieceKind[] {
|
||||
const bag = [...PIECE_KINDS]
|
||||
for (let index = bag.length - 1; index > 0; index -= 1) {
|
||||
const target = Math.floor(
|
||||
Math.max(0, Math.min(0.999999, random())) * (index + 1),
|
||||
)
|
||||
;[bag[index], bag[target]] = [bag[target], bag[index]]
|
||||
}
|
||||
return bag
|
||||
}
|
||||
|
||||
export function getNeonDropCells(piece: NeonDropPiece): NeonDropPoint[] {
|
||||
return SHAPES[piece.kind][piece.rotation % SHAPES[piece.kind].length].map(
|
||||
(point) => ({ x: piece.x + point.x, y: piece.y + point.y }),
|
||||
)
|
||||
}
|
||||
|
||||
export function createNeonDropPiece(kind: NeonDropPieceKind): NeonDropPiece {
|
||||
const points = SHAPES[kind][0]
|
||||
const minX = Math.min(...points.map((point) => point.x))
|
||||
const maxX = Math.max(...points.map((point) => point.x))
|
||||
const minY = Math.min(...points.map((point) => point.y))
|
||||
return {
|
||||
kind,
|
||||
rotation: 0,
|
||||
x: Math.floor((NEON_DROP_COLUMNS - (maxX - minX + 1)) / 2) - minX,
|
||||
y: -minY,
|
||||
}
|
||||
}
|
||||
|
||||
export function canPlaceNeonDropPiece(
|
||||
board: NeonDropCell[][],
|
||||
piece: NeonDropPiece,
|
||||
): boolean {
|
||||
return getNeonDropCells(piece).every(
|
||||
({ x, y }) =>
|
||||
x >= 0 &&
|
||||
x < NEON_DROP_COLUMNS &&
|
||||
y < NEON_DROP_ROWS &&
|
||||
(y < 0 || board[y][x] === null),
|
||||
)
|
||||
}
|
||||
|
||||
export function createNeonDropGame(
|
||||
random: () => number = Math.random,
|
||||
): NeonDropGameState {
|
||||
const queue = shuffleBag(random)
|
||||
const kind = queue.shift() as NeonDropPieceKind
|
||||
return {
|
||||
active: createNeonDropPiece(kind),
|
||||
board: createNeonDropBoard(),
|
||||
level: 1,
|
||||
lines: 0,
|
||||
nextKind: queue[0],
|
||||
queue,
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function moveNeonDropPiece(
|
||||
state: NeonDropGameState,
|
||||
direction: -1 | 1,
|
||||
): NeonDropActionResult {
|
||||
if (state.status !== 'playing')
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
const active = { ...state.active, x: state.active.x + direction }
|
||||
if (!canPlaceNeonDropPiece(state.board, active)) {
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
}
|
||||
return { clearedLines: 0, event: 'move', state: { ...state, active } }
|
||||
}
|
||||
|
||||
export function rotateNeonDropPiece(
|
||||
state: NeonDropGameState,
|
||||
): NeonDropActionResult {
|
||||
if (state.status !== 'playing')
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
const rotation =
|
||||
(state.active.rotation + 1) % SHAPES[state.active.kind].length
|
||||
for (const offset of [0, -1, 1, -2, 2]) {
|
||||
const active = { ...state.active, rotation, x: state.active.x + offset }
|
||||
if (canPlaceNeonDropPiece(state.board, active)) {
|
||||
return { clearedLines: 0, event: 'rotate', state: { ...state, active } }
|
||||
}
|
||||
}
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
}
|
||||
|
||||
export function clearCompletedNeonDropLines(board: NeonDropCell[][]): {
|
||||
board: NeonDropCell[][]
|
||||
clearedLines: number
|
||||
} {
|
||||
const remaining = board.filter((row) => row.some((cell) => cell === null))
|
||||
const clearedLines = NEON_DROP_ROWS - remaining.length
|
||||
return {
|
||||
board: [
|
||||
...Array.from({ length: clearedLines }, () =>
|
||||
Array<NeonDropCell>(NEON_DROP_COLUMNS).fill(null),
|
||||
),
|
||||
...remaining.map((row) => [...row]),
|
||||
],
|
||||
clearedLines,
|
||||
}
|
||||
}
|
||||
|
||||
function lockNeonDropPiece(
|
||||
state: NeonDropGameState,
|
||||
random: () => number,
|
||||
): NeonDropActionResult {
|
||||
const board = state.board.map((row) => [...row])
|
||||
for (const { x, y } of getNeonDropCells(state.active)) {
|
||||
if (y < 0) {
|
||||
return {
|
||||
clearedLines: 0,
|
||||
event: 'game-over',
|
||||
state: { ...state, status: 'over' },
|
||||
}
|
||||
}
|
||||
board[y][x] = state.active.kind
|
||||
}
|
||||
|
||||
const cleared = clearCompletedNeonDropLines(board)
|
||||
let queue = state.queue.slice(1)
|
||||
if (queue.length === 0) queue = shuffleBag(random)
|
||||
const active = createNeonDropPiece(state.nextKind)
|
||||
const lines = state.lines + cleared.clearedLines
|
||||
const level = Math.floor(lines / NEON_DROP_LINES_PER_LEVEL) + 1
|
||||
const score = state.score + LINE_SCORES[cleared.clearedLines] * state.level
|
||||
const nextState: NeonDropGameState = {
|
||||
...state,
|
||||
active,
|
||||
board: cleared.board,
|
||||
level,
|
||||
lines,
|
||||
nextKind: queue[0],
|
||||
queue,
|
||||
score,
|
||||
}
|
||||
|
||||
if (!canPlaceNeonDropPiece(nextState.board, active)) {
|
||||
return {
|
||||
clearedLines: cleared.clearedLines,
|
||||
event: 'game-over',
|
||||
state: { ...nextState, status: 'over' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
clearedLines: cleared.clearedLines,
|
||||
event: cleared.clearedLines > 0 ? 'clear' : 'lock',
|
||||
state: nextState,
|
||||
}
|
||||
}
|
||||
|
||||
export function stepNeonDrop(
|
||||
state: NeonDropGameState,
|
||||
random: () => number = Math.random,
|
||||
manual = false,
|
||||
): NeonDropActionResult {
|
||||
if (state.status !== 'playing')
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
const active = { ...state.active, y: state.active.y + 1 }
|
||||
if (canPlaceNeonDropPiece(state.board, active)) {
|
||||
return {
|
||||
clearedLines: 0,
|
||||
event: manual ? 'move' : 'none',
|
||||
state: { ...state, active, score: state.score + (manual ? 1 : 0) },
|
||||
}
|
||||
}
|
||||
return lockNeonDropPiece(state, random)
|
||||
}
|
||||
|
||||
export function getNeonDropGhostPiece(state: NeonDropGameState): NeonDropPiece {
|
||||
let active = { ...state.active }
|
||||
while (canPlaceNeonDropPiece(state.board, { ...active, y: active.y + 1 })) {
|
||||
active = { ...active, y: active.y + 1 }
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
export function hardDropNeonPiece(
|
||||
state: NeonDropGameState,
|
||||
random: () => number = Math.random,
|
||||
): NeonDropActionResult {
|
||||
if (state.status !== 'playing')
|
||||
return { clearedLines: 0, event: 'none', state }
|
||||
const active = getNeonDropGhostPiece(state)
|
||||
const distance = active.y - state.active.y
|
||||
return lockNeonDropPiece(
|
||||
{ ...state, active, score: state.score + distance * 2 },
|
||||
random,
|
||||
)
|
||||
}
|
||||
|
||||
export function getNeonDropInterval(level: number): number {
|
||||
return Math.max(110, 720 - (Math.max(1, level) - 1) * 55)
|
||||
}
|
||||
|
||||
export function pauseNeonDrop(state: NeonDropGameState): NeonDropGameState {
|
||||
return state.status === 'playing' ? { ...state, status: 'paused' } : state
|
||||
}
|
||||
|
||||
export function resumeNeonDrop(state: NeonDropGameState): NeonDropGameState {
|
||||
return state.status === 'paused' ? { ...state, status: 'playing' } : state
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
createNeonDropGame,
|
||||
hardDropNeonPiece,
|
||||
moveNeonDropPiece,
|
||||
pauseNeonDrop,
|
||||
resumeNeonDrop,
|
||||
rotateNeonDropPiece,
|
||||
stepNeonDrop,
|
||||
} from './engine'
|
||||
import type { NeonDropEvent, NeonDropGameState } from './types'
|
||||
|
||||
type NeonDropSave = {
|
||||
bestLines: number
|
||||
bestScore: number
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
export const useNeonDropStore = defineStore('neon-drop', {
|
||||
state: () => ({
|
||||
bestLines: 0,
|
||||
bestScore: 0,
|
||||
game: null as NeonDropGameState | null,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
const saved = useGamesStore().readGame<Partial<NeonDropSave>>('neon-drop')
|
||||
this.bestLines =
|
||||
typeof saved?.bestLines === 'number' && saved.bestLines >= 0
|
||||
? Math.floor(saved.bestLines)
|
||||
: 0
|
||||
this.bestScore =
|
||||
typeof saved?.bestScore === 'number' && saved.bestScore >= 0
|
||||
? Math.floor(saved.bestScore)
|
||||
: 0
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('neon-drop', {
|
||||
bestLines: this.bestLines,
|
||||
bestScore: this.bestScore,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies NeonDropSave)
|
||||
},
|
||||
start(): void {
|
||||
this.game = createNeonDropGame()
|
||||
this.menuOpen = false
|
||||
},
|
||||
applyResult(result: {
|
||||
event: NeonDropEvent
|
||||
state: NeonDropGameState
|
||||
}): NeonDropEvent {
|
||||
this.game = result.state
|
||||
if (result.state.status === 'over') {
|
||||
this.bestLines = Math.max(this.bestLines, result.state.lines)
|
||||
this.bestScore = Math.max(this.bestScore, result.state.score)
|
||||
this.persist()
|
||||
}
|
||||
return result.event
|
||||
},
|
||||
move(direction: -1 | 1): NeonDropEvent {
|
||||
if (!this.game) return 'none'
|
||||
return this.applyResult(moveNeonDropPiece(this.game, direction))
|
||||
},
|
||||
rotate(): NeonDropEvent {
|
||||
if (!this.game) return 'none'
|
||||
return this.applyResult(rotateNeonDropPiece(this.game))
|
||||
},
|
||||
softDrop(): NeonDropEvent {
|
||||
if (!this.game) return 'none'
|
||||
return this.applyResult(stepNeonDrop(this.game, Math.random, true))
|
||||
},
|
||||
hardDrop(): NeonDropEvent {
|
||||
if (!this.game) return 'none'
|
||||
return this.applyResult(hardDropNeonPiece(this.game))
|
||||
},
|
||||
tick(): NeonDropEvent {
|
||||
if (!this.game) return 'none'
|
||||
return this.applyResult(stepNeonDrop(this.game))
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game) this.game = pauseNeonDrop(this.game)
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game) {
|
||||
this.game = resumeNeonDrop(this.game)
|
||||
this.menuOpen = false
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.menuOpen = true
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
export type NeonDropPieceKind = 'I' | 'J' | 'L' | 'O' | 'S' | 'T' | 'Z'
|
||||
export type NeonDropCell = NeonDropPieceKind | null
|
||||
export type NeonDropStatus = 'over' | 'paused' | 'playing'
|
||||
export type NeonDropEvent =
|
||||
| 'clear'
|
||||
| 'drop'
|
||||
| 'game-over'
|
||||
| 'lock'
|
||||
| 'move'
|
||||
| 'none'
|
||||
| 'rotate'
|
||||
|
||||
export type NeonDropPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type NeonDropPiece = {
|
||||
kind: NeonDropPieceKind
|
||||
rotation: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type NeonDropGameState = {
|
||||
active: NeonDropPiece
|
||||
board: NeonDropCell[][]
|
||||
level: number
|
||||
lines: number
|
||||
nextKind: NeonDropPieceKind
|
||||
queue: NeonDropPieceKind[]
|
||||
score: number
|
||||
status: NeonDropStatus
|
||||
}
|
||||
|
||||
export type NeonDropActionResult = {
|
||||
clearedLines: number
|
||||
event: NeonDropEvent
|
||||
state: NeonDropGameState
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
||||
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
||||
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
||||
import winUrl from '@/assets/audio/number-merge/win.wav?url'
|
||||
|
||||
export type NumberMergeSound = 'game-over' | 'merge' | 'move' | 'win'
|
||||
|
||||
const soundUrls: Record<NumberMergeSound, string> = {
|
||||
'game-over': gameOverUrl,
|
||||
merge: mergeUrl,
|
||||
move: moveUrl,
|
||||
win: winUrl,
|
||||
}
|
||||
const playerPools = new Map<NumberMergeSound, HTMLAudioElement[]>()
|
||||
|
||||
function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
|
||||
const existing = playerPools.get(sound)
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.82
|
||||
return player
|
||||
})
|
||||
playerPools.set(sound, players)
|
||||
return players
|
||||
}
|
||||
|
||||
export function playNumberMergeSound(
|
||||
sound: NumberMergeSound,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (!enabled) return
|
||||
|
||||
const players = getPlayers(sound)
|
||||
const player = players.find((candidate) => candidate.paused) ?? players[0]
|
||||
player.currentTime = 0
|
||||
void player.play().catch((error: unknown) => {
|
||||
console.error(`[2048 audio] Failed to play ${sound}`, error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
canMoveNumberMerge,
|
||||
continueNumberMerge,
|
||||
createNumberMergeGame,
|
||||
isNumberMergeGameState,
|
||||
moveNumberMerge,
|
||||
} from './engine'
|
||||
import type { NumberMergeGameState } from './types'
|
||||
|
||||
function gameFromGrid(grid: number[][]): NumberMergeGameState {
|
||||
let nextTileId = 1
|
||||
return {
|
||||
hasWon: false,
|
||||
keepPlaying: false,
|
||||
nextTileId: grid.flat().filter(Boolean).length + 1,
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
tiles: grid.flatMap((row, rowIndex) =>
|
||||
row.flatMap((value, columnIndex) =>
|
||||
value
|
||||
? [
|
||||
{
|
||||
column: columnIndex,
|
||||
id: nextTileId++,
|
||||
isNew: false,
|
||||
merged: false,
|
||||
row: rowIndex,
|
||||
value,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function values(state: NumberMergeGameState): number[][] {
|
||||
return Array.from({ length: 4 }, (_, row) =>
|
||||
Array.from(
|
||||
{ length: 4 },
|
||||
(_, column) =>
|
||||
state.tiles.find(
|
||||
(tile) => tile.row === row && tile.column === column,
|
||||
)?.value ?? 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const spawnFirstTwo = () => 0
|
||||
|
||||
describe('number merge engine', () => {
|
||||
it('starts with two tiles on different cells', () => {
|
||||
const game = createNumberMergeGame(spawnFirstTwo)
|
||||
expect(game.tiles).toHaveLength(2)
|
||||
expect(game.tiles.map((tile) => tile.value)).toEqual([2, 2])
|
||||
expect(
|
||||
new Set(game.tiles.map((tile) => `${tile.row}:${tile.column}`)).size,
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('compresses and merges a row to the left', () => {
|
||||
const result = moveNumberMerge(
|
||||
gameFromGrid([
|
||||
[2, 0, 2, 4],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
]),
|
||||
'left',
|
||||
spawnFirstTwo,
|
||||
)
|
||||
expect(values(result.state)[0].slice(0, 3)).toEqual([4, 4, 2])
|
||||
expect(result.gained).toBe(4)
|
||||
})
|
||||
|
||||
it('merges each tile only once per move', () => {
|
||||
const result = moveNumberMerge(
|
||||
gameFromGrid([
|
||||
[2, 2, 2, 2],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
]),
|
||||
'left',
|
||||
spawnFirstTwo,
|
||||
)
|
||||
expect(values(result.state)[0].slice(0, 3)).toEqual([4, 4, 2])
|
||||
expect(result.gained).toBe(8)
|
||||
})
|
||||
|
||||
it('moves columns upward using the same merge rules', () => {
|
||||
const result = moveNumberMerge(
|
||||
gameFromGrid([
|
||||
[2, 0, 0, 0],
|
||||
[2, 0, 0, 0],
|
||||
[4, 0, 0, 0],
|
||||
[4, 0, 0, 0],
|
||||
]),
|
||||
'up',
|
||||
spawnFirstTwo,
|
||||
)
|
||||
expect(values(result.state).map((row) => row[0])).toEqual([4, 8, 0, 0])
|
||||
expect(result.state.tiles).toHaveLength(3)
|
||||
expect(result.gained).toBe(12)
|
||||
})
|
||||
|
||||
it('does not spawn a tile after an invalid move', () => {
|
||||
const game = gameFromGrid([
|
||||
[2, 4, 8, 16],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
])
|
||||
const result = moveNumberMerge(game, 'left', spawnFirstTwo)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.state).toBe(game)
|
||||
})
|
||||
|
||||
it('recognizes a board with no remaining moves', () => {
|
||||
const game = gameFromGrid([
|
||||
[2, 4, 2, 4],
|
||||
[4, 2, 4, 2],
|
||||
[2, 4, 2, 4],
|
||||
[4, 2, 4, 2],
|
||||
])
|
||||
expect(canMoveNumberMerge(game)).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses at 2048 and can continue afterward', () => {
|
||||
const result = moveNumberMerge(
|
||||
gameFromGrid([
|
||||
[1024, 1024, 0, 0],
|
||||
[2, 4, 8, 16],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
]),
|
||||
'left',
|
||||
spawnFirstTwo,
|
||||
)
|
||||
expect(result.state.status).toBe('won')
|
||||
expect(result.state.hasWon).toBe(true)
|
||||
expect(continueNumberMerge(result.state).status).toBe('playing')
|
||||
})
|
||||
|
||||
it('rejects malformed persisted games', () => {
|
||||
expect(isNumberMergeGameState(gameFromGrid([[2], [], [], []]))).toBe(true)
|
||||
expect(
|
||||
isNumberMergeGameState({
|
||||
...gameFromGrid([[2], [], [], []]),
|
||||
tiles: [{ column: 8, id: 1, isNew: false, merged: false, row: 0, value: 3 }],
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
NumberMergeDirection,
|
||||
NumberMergeGameState,
|
||||
NumberMergeMoveResult,
|
||||
NumberMergeTile,
|
||||
} from './types'
|
||||
|
||||
export const NUMBER_MERGE_BOARD_SIZE = 4
|
||||
export const NUMBER_MERGE_WIN_VALUE = 2048
|
||||
|
||||
type Point = {
|
||||
column: number
|
||||
row: number
|
||||
}
|
||||
|
||||
function pointKey(point: Point): string {
|
||||
return `${point.row}:${point.column}`
|
||||
}
|
||||
|
||||
function linePoints(direction: NumberMergeDirection, line: number): Point[] {
|
||||
return Array.from({ length: NUMBER_MERGE_BOARD_SIZE }, (_, offset) => {
|
||||
if (direction === 'left') return { column: offset, row: line }
|
||||
if (direction === 'right') {
|
||||
return { column: NUMBER_MERGE_BOARD_SIZE - 1 - offset, row: line }
|
||||
}
|
||||
if (direction === 'up') return { column: line, row: offset }
|
||||
return { column: line, row: NUMBER_MERGE_BOARD_SIZE - 1 - offset }
|
||||
})
|
||||
}
|
||||
|
||||
function availablePoints(tiles: NumberMergeTile[]): Point[] {
|
||||
const occupied = new Set(tiles.map(pointKey))
|
||||
const points: Point[] = []
|
||||
|
||||
for (let row = 0; row < NUMBER_MERGE_BOARD_SIZE; row += 1) {
|
||||
for (let column = 0; column < NUMBER_MERGE_BOARD_SIZE; column += 1) {
|
||||
const point = { column, row }
|
||||
if (!occupied.has(pointKey(point))) points.push(point)
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
function isPowerOfTwo(value: number): boolean {
|
||||
return value >= 2 && Number.isInteger(Math.log2(value))
|
||||
}
|
||||
|
||||
export function spawnNumberMergeTile(
|
||||
state: NumberMergeGameState,
|
||||
random: () => number = Math.random,
|
||||
): NumberMergeGameState {
|
||||
const points = availablePoints(state.tiles)
|
||||
if (points.length === 0) return state
|
||||
|
||||
const pointIndex = Math.min(
|
||||
points.length - 1,
|
||||
Math.floor(random() * points.length),
|
||||
)
|
||||
const point = points[pointIndex]
|
||||
const tile: NumberMergeTile = {
|
||||
...point,
|
||||
id: state.nextTileId,
|
||||
isNew: true,
|
||||
merged: false,
|
||||
value: random() < 0.9 ? 2 : 4,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
nextTileId: state.nextTileId + 1,
|
||||
tiles: [...state.tiles, tile],
|
||||
}
|
||||
}
|
||||
|
||||
export function canMoveNumberMerge(state: NumberMergeGameState): boolean {
|
||||
if (state.tiles.length < NUMBER_MERGE_BOARD_SIZE ** 2) return true
|
||||
|
||||
const values = new Map(state.tiles.map((tile) => [pointKey(tile), tile.value]))
|
||||
for (const tile of state.tiles) {
|
||||
const right = values.get(pointKey({ column: tile.column + 1, row: tile.row }))
|
||||
const down = values.get(pointKey({ column: tile.column, row: tile.row + 1 }))
|
||||
if (right === tile.value || down === tile.value) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function createNumberMergeGame(
|
||||
random: () => number = Math.random,
|
||||
): NumberMergeGameState {
|
||||
const empty: NumberMergeGameState = {
|
||||
hasWon: false,
|
||||
keepPlaying: false,
|
||||
nextTileId: 1,
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
tiles: [],
|
||||
}
|
||||
return spawnNumberMergeTile(spawnNumberMergeTile(empty, random), random)
|
||||
}
|
||||
|
||||
export function moveNumberMerge(
|
||||
state: NumberMergeGameState,
|
||||
direction: NumberMergeDirection,
|
||||
random: () => number = Math.random,
|
||||
): NumberMergeMoveResult {
|
||||
if (state.status !== 'playing') {
|
||||
return { changed: false, gained: 0, state }
|
||||
}
|
||||
|
||||
const tileByPoint = new Map(state.tiles.map((tile) => [pointKey(tile), tile]))
|
||||
const nextTiles: NumberMergeTile[] = []
|
||||
let gained = 0
|
||||
|
||||
for (let line = 0; line < NUMBER_MERGE_BOARD_SIZE; line += 1) {
|
||||
const points = linePoints(direction, line)
|
||||
const tiles = points
|
||||
.map((point) => tileByPoint.get(pointKey(point)))
|
||||
.filter((tile): tile is NumberMergeTile => tile !== undefined)
|
||||
|
||||
let sourceIndex = 0
|
||||
let targetIndex = 0
|
||||
while (sourceIndex < tiles.length) {
|
||||
const tile = tiles[sourceIndex]
|
||||
const nextTile = tiles[sourceIndex + 1]
|
||||
const target = points[targetIndex]
|
||||
|
||||
if (nextTile?.value === tile.value) {
|
||||
const value = tile.value * 2
|
||||
nextTiles.push({
|
||||
...tile,
|
||||
...target,
|
||||
isNew: false,
|
||||
merged: true,
|
||||
value,
|
||||
})
|
||||
gained += value
|
||||
sourceIndex += 2
|
||||
} else {
|
||||
nextTiles.push({
|
||||
...tile,
|
||||
...target,
|
||||
isNew: false,
|
||||
merged: false,
|
||||
})
|
||||
sourceIndex += 1
|
||||
}
|
||||
targetIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
const changed =
|
||||
nextTiles.length !== state.tiles.length ||
|
||||
nextTiles.some((tile) => {
|
||||
const previous = state.tiles.find((candidate) => candidate.id === tile.id)
|
||||
return (
|
||||
!previous ||
|
||||
previous.column !== tile.column ||
|
||||
previous.row !== tile.row ||
|
||||
previous.value !== tile.value
|
||||
)
|
||||
})
|
||||
|
||||
if (!changed) return { changed: false, gained: 0, state }
|
||||
|
||||
let nextState = spawnNumberMergeTile(
|
||||
{
|
||||
...state,
|
||||
score: state.score + gained,
|
||||
tiles: nextTiles,
|
||||
},
|
||||
random,
|
||||
)
|
||||
const reachedWin = nextState.tiles.some(
|
||||
(tile) => tile.value >= NUMBER_MERGE_WIN_VALUE,
|
||||
)
|
||||
const firstWin = reachedWin && !state.hasWon
|
||||
nextState = {
|
||||
...nextState,
|
||||
hasWon: state.hasWon || reachedWin,
|
||||
status: firstWin
|
||||
? 'won'
|
||||
: canMoveNumberMerge(nextState)
|
||||
? 'playing'
|
||||
: 'game-over',
|
||||
}
|
||||
|
||||
return { changed: true, gained, state: nextState }
|
||||
}
|
||||
|
||||
export function continueNumberMerge(
|
||||
state: NumberMergeGameState,
|
||||
): NumberMergeGameState {
|
||||
if (state.status !== 'won') return state
|
||||
return {
|
||||
...state,
|
||||
keepPlaying: true,
|
||||
status: canMoveNumberMerge(state) ? 'playing' : 'game-over',
|
||||
}
|
||||
}
|
||||
|
||||
export function isNumberMergeGameState(
|
||||
value: unknown,
|
||||
): value is NumberMergeGameState {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const game = value as Partial<NumberMergeGameState>
|
||||
if (
|
||||
typeof game.hasWon !== 'boolean' ||
|
||||
typeof game.keepPlaying !== 'boolean' ||
|
||||
!Number.isInteger(game.nextTileId) ||
|
||||
(game.nextTileId ?? 0) < 1 ||
|
||||
!Number.isInteger(game.score) ||
|
||||
(game.score ?? -1) < 0 ||
|
||||
!['game-over', 'playing', 'won'].includes(game.status ?? '') ||
|
||||
!Array.isArray(game.tiles) ||
|
||||
game.tiles.length > NUMBER_MERGE_BOARD_SIZE ** 2
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ids = new Set<number>()
|
||||
const points = new Set<string>()
|
||||
for (const candidate of game.tiles) {
|
||||
if (!candidate || typeof candidate !== 'object') return false
|
||||
const tile = candidate as Partial<NumberMergeTile>
|
||||
if (
|
||||
!Number.isInteger(tile.id) ||
|
||||
(tile.id ?? 0) < 1 ||
|
||||
!Number.isInteger(tile.column) ||
|
||||
(tile.column ?? -1) < 0 ||
|
||||
(tile.column ?? NUMBER_MERGE_BOARD_SIZE) >= NUMBER_MERGE_BOARD_SIZE ||
|
||||
!Number.isInteger(tile.row) ||
|
||||
(tile.row ?? -1) < 0 ||
|
||||
(tile.row ?? NUMBER_MERGE_BOARD_SIZE) >= NUMBER_MERGE_BOARD_SIZE ||
|
||||
typeof tile.value !== 'number' ||
|
||||
!isPowerOfTwo(tile.value) ||
|
||||
typeof tile.isNew !== 'boolean' ||
|
||||
typeof tile.merged !== 'boolean'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const position = pointKey(tile as Point)
|
||||
if (ids.has(tile.id as number) || points.has(position)) return false
|
||||
ids.add(tile.id as number)
|
||||
points.add(position)
|
||||
}
|
||||
|
||||
return Math.max(0, ...ids) < (game.nextTileId as number)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
|
||||
import {
|
||||
continueNumberMerge,
|
||||
createNumberMergeGame,
|
||||
isNumberMergeGameState,
|
||||
moveNumberMerge,
|
||||
} from './engine'
|
||||
import type {
|
||||
NumberMergeDirection,
|
||||
NumberMergeGameState,
|
||||
NumberMergeMoveResult,
|
||||
} from './types'
|
||||
|
||||
type NumberMergeSave = {
|
||||
bestScore: number
|
||||
game: NumberMergeGameState | null
|
||||
highestTile: number
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function savedNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: 0
|
||||
}
|
||||
|
||||
export const useNumberMergeStore = defineStore('number-merge', {
|
||||
state: () => ({
|
||||
bestScore: 0,
|
||||
game: null as NumberMergeGameState | null,
|
||||
highestTile: 0,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<NumberMergeSave>>(
|
||||
'number-merge',
|
||||
)
|
||||
this.game = isNumberMergeGameState(saved?.game)
|
||||
? {
|
||||
...cloneJsonData(saved.game),
|
||||
tiles: saved.game.tiles.map((tile) => ({
|
||||
...tile,
|
||||
isNew: false,
|
||||
merged: false,
|
||||
})),
|
||||
}
|
||||
: null
|
||||
this.bestScore = Math.max(
|
||||
savedNumber(saved?.bestScore),
|
||||
this.game?.score ?? 0,
|
||||
)
|
||||
this.highestTile = Math.max(
|
||||
savedNumber(saved?.highestTile),
|
||||
...(this.game?.tiles.map((tile) => tile.value) ?? [0]),
|
||||
)
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('number-merge', {
|
||||
bestScore: this.bestScore,
|
||||
game: this.game,
|
||||
highestTile: this.highestTile,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies NumberMergeSave)
|
||||
},
|
||||
start(): void {
|
||||
this.game = createNumberMergeGame()
|
||||
this.highestTile = Math.max(
|
||||
this.highestTile,
|
||||
...this.game.tiles.map((tile) => tile.value),
|
||||
)
|
||||
this.menuOpen = false
|
||||
this.persist()
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game) this.menuOpen = false
|
||||
},
|
||||
showMenu(): void {
|
||||
this.menuOpen = true
|
||||
},
|
||||
move(direction: NumberMergeDirection): NumberMergeMoveResult | null {
|
||||
if (!this.game) return null
|
||||
|
||||
const result = moveNumberMerge(this.game, direction)
|
||||
if (!result.changed) return result
|
||||
|
||||
this.game = result.state
|
||||
this.bestScore = Math.max(this.bestScore, result.state.score)
|
||||
this.highestTile = Math.max(
|
||||
this.highestTile,
|
||||
...result.state.tiles.map((tile) => tile.value),
|
||||
)
|
||||
this.persist()
|
||||
return result
|
||||
},
|
||||
continueAfterWin(): void {
|
||||
if (!this.game) return
|
||||
this.game = continueNumberMerge(this.game)
|
||||
this.persist()
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export type NumberMergeDirection = 'down' | 'left' | 'right' | 'up'
|
||||
export type NumberMergeStatus = 'game-over' | 'playing' | 'won'
|
||||
|
||||
export type NumberMergeTile = {
|
||||
column: number
|
||||
id: number
|
||||
isNew: boolean
|
||||
merged: boolean
|
||||
row: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export type NumberMergeGameState = {
|
||||
hasWon: boolean
|
||||
keepPlaying: boolean
|
||||
nextTileId: number
|
||||
score: number
|
||||
status: NumberMergeStatus
|
||||
tiles: NumberMergeTile[]
|
||||
}
|
||||
|
||||
export type NumberMergeMoveResult = {
|
||||
changed: boolean
|
||||
gained: number
|
||||
state: NumberMergeGameState
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<svg
|
||||
class="sky-flappy-bird"
|
||||
viewBox="0 0 104 72"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sky-bird-body"
|
||||
x1="20"
|
||||
y1="24"
|
||||
x2="84"
|
||||
y2="54"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#5fc1d1" />
|
||||
<stop offset="0.58" stop-color="#287d99" />
|
||||
<stop offset="1" stop-color="#16546f" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="sky-bird-wing"
|
||||
x1="40"
|
||||
y1="12"
|
||||
x2="61"
|
||||
y2="55"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#b5e5e7" />
|
||||
<stop offset="0.48" stop-color="#579eb0" />
|
||||
<stop offset="1" stop-color="#245f78" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="sky-flappy-bird__far-wing">
|
||||
<path d="M50 38C39 29 30 15 34 5c10 4 22 16 29 31Z" />
|
||||
<path d="M39 13c7 5 14 13 19 22" />
|
||||
</g>
|
||||
<path class="sky-flappy-bird__tail" d="M29 36 5 25l12 15L3 49l29-4Z" />
|
||||
<path
|
||||
class="sky-flappy-bird__body"
|
||||
d="M20 40c8-12 24-17 45-14 12 1 20 7 25 14-8 10-21 13-36 12-15-1-27-4-34-12Z"
|
||||
/>
|
||||
<path
|
||||
class="sky-flappy-bird__belly"
|
||||
d="M24 43c13 4 31 5 45 1 7-2 12-5 16-9 2 2 4 4 5 6-8 10-21 13-36 12-13-1-24-4-30-10Z"
|
||||
/>
|
||||
<path
|
||||
class="sky-flappy-bird__neck"
|
||||
d="M67 27c7-8 18-9 24-2 4 4 4 10 1 16-8 1-16-3-23-9Z"
|
||||
/>
|
||||
|
||||
<g class="sky-flappy-bird__wing">
|
||||
<path
|
||||
class="sky-flappy-bird__wing-shape"
|
||||
d="M52 38C42 31 35 18 40 6c10 5 20 16 27 29-1 8-7 17-19 25 2-8 3-15 4-22Z"
|
||||
/>
|
||||
<path
|
||||
class="sky-flappy-bird__feather"
|
||||
d="M44 16c8 7 14 15 18 23M46 27c7 5 11 11 13 18M48 39c4 3 7 6 8 10"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<path
|
||||
class="sky-flappy-bird__face"
|
||||
d="M76 26c5-5 12-4 16 1-4 1-7 4-9 8-4-1-7-4-7-9Z"
|
||||
/>
|
||||
<circle class="sky-flappy-bird__eye-ring" cx="86" cy="27" r="3.1" />
|
||||
<circle class="sky-flappy-bird__eye" cx="86.7" cy="27" r="1.45" />
|
||||
<path class="sky-flappy-bird__beak" d="m91 31 12 5-12 4c2-3 2-6 0-9Z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||
|
||||
export type SkyFlappySound = 'crash' | 'flap' | 'point'
|
||||
const urls: Record<SkyFlappySound, string> = { crash: crashUrl, flap: flapUrl, point: pointUrl }
|
||||
const pools = new Map<SkyFlappySound, HTMLAudioElement[]>()
|
||||
|
||||
export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): void {
|
||||
if (!enabled) return
|
||||
let players = pools.get(sound)
|
||||
if (!players) {
|
||||
players = Array.from({ length: 3 }, () => {
|
||||
const player = new Audio(urls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
})
|
||||
pools.set(sound, players)
|
||||
}
|
||||
const player = players.find((candidate) => candidate.paused) ?? players[0]
|
||||
player.currentTime = 0
|
||||
void player.play().catch((error: unknown) => console.error(`[Sky Flappy audio] Failed to play ${sound}`, error))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSkyFlappyGame,
|
||||
FLAPPY_GAP_HEIGHT,
|
||||
FLAPPY_MAX_SPEED,
|
||||
FLAPPY_MAX_GAP_TOP,
|
||||
FLAPPY_MIN_GAP_HEIGHT,
|
||||
FLAPPY_MIN_GAP_TOP,
|
||||
flapSkyGlider,
|
||||
getSkyFlappyDifficulty,
|
||||
stepSkyFlappy,
|
||||
} from './engine'
|
||||
|
||||
describe('sky flappy engine', () => {
|
||||
it('starts in a ready state', () => {
|
||||
expect(createSkyFlappyGame()).toMatchObject({ playerY: 48, score: 0, status: 'ready' })
|
||||
})
|
||||
|
||||
it('starts and applies an upward impulse on flap', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
expect(state.status).toBe('playing')
|
||||
expect(state.playerVelocity).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('applies deterministic time-based physics', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
expect(stepSkyFlappy(state, 0.1, () => 0.5)).toEqual(
|
||||
stepSkyFlappy(state, 0.1, () => 0.5),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps generated gaps inside playable bounds', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
const low = stepSkyFlappy(state, 0.01, () => 0)
|
||||
const high = stepSkyFlappy(state, 0.01, () => 1)
|
||||
expect(low.obstacles[0].gapTop).toBe(FLAPPY_MIN_GAP_TOP)
|
||||
expect(high.obstacles[0].gapTop).toBe(FLAPPY_MAX_GAP_TOP)
|
||||
expect(high.obstacles[0].gapTop + FLAPPY_GAP_HEIGHT).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('increases speed and narrows the gap as the score rises', () => {
|
||||
const start = getSkyFlappyDifficulty(0)
|
||||
const advanced = getSkyFlappyDifficulty(20)
|
||||
const maximum = getSkyFlappyDifficulty(100)
|
||||
|
||||
expect(advanced.speed).toBeGreaterThan(start.speed)
|
||||
expect(advanced.gapHeight).toBeLessThan(start.gapHeight)
|
||||
expect(maximum.speed).toBe(FLAPPY_MAX_SPEED)
|
||||
expect(maximum.gapHeight).toBe(FLAPPY_MIN_GAP_HEIGHT)
|
||||
})
|
||||
|
||||
it('scores an obstacle only once', () => {
|
||||
const state = {
|
||||
...flapSkyGlider(createSkyFlappyGame()),
|
||||
obstacles: [{ gapHeight: FLAPPY_GAP_HEIGHT, gapTop: 30, id: 1, scored: false, x: 7 }],
|
||||
}
|
||||
const scored = stepSkyFlappy(state, 0.01, () => 0.5)
|
||||
expect(scored.score).toBe(1)
|
||||
expect(stepSkyFlappy(scored, 0.01, () => 0.5).score).toBe(1)
|
||||
})
|
||||
|
||||
it('detects collision using the player edges', () => {
|
||||
const state = {
|
||||
...flapSkyGlider(createSkyFlappyGame()),
|
||||
obstacles: [{ gapHeight: FLAPPY_GAP_HEIGHT, gapTop: 40, id: 1, scored: false, x: 22 }],
|
||||
playerY: 41,
|
||||
playerVelocity: 0,
|
||||
}
|
||||
expect(stepSkyFlappy(state, 0.01).status).toBe('over')
|
||||
})
|
||||
|
||||
it('ends at the upper and lower boundaries', () => {
|
||||
const upper = { ...flapSkyGlider(createSkyFlappyGame()), playerY: 1 }
|
||||
const lower = { ...flapSkyGlider(createSkyFlappyGame()), playerY: 99 }
|
||||
expect(stepSkyFlappy(upper, 0.01).status).toBe('over')
|
||||
expect(stepSkyFlappy(lower, 0.01).status).toBe('over')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { SkyFlappyGameState, SkyFlappyObstacle } from './types'
|
||||
|
||||
export const FLAPPY_PLAYER_X = 23
|
||||
export const FLAPPY_PLAYER_RADIUS = 3.2
|
||||
export const FLAPPY_GRAVITY = 82
|
||||
export const FLAPPY_IMPULSE = -34
|
||||
export const FLAPPY_OBSTACLE_WIDTH = 14
|
||||
export const FLAPPY_GAP_HEIGHT = 29
|
||||
export const FLAPPY_MIN_GAP_HEIGHT = 22
|
||||
export const FLAPPY_MIN_GAP_TOP = 15
|
||||
export const FLAPPY_MAX_GAP_TOP = 56
|
||||
export const FLAPPY_BASE_SPEED = 20.5
|
||||
export const FLAPPY_MAX_SPEED = 40
|
||||
|
||||
export function getSkyFlappyDifficulty(score: number): {
|
||||
gapHeight: number
|
||||
speed: number
|
||||
} {
|
||||
return {
|
||||
gapHeight: Math.max(FLAPPY_MIN_GAP_HEIGHT, FLAPPY_GAP_HEIGHT - score * 0.35),
|
||||
speed: Math.min(FLAPPY_MAX_SPEED, FLAPPY_BASE_SPEED + score * 0.65),
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkyFlappyGame(): SkyFlappyGameState {
|
||||
return {
|
||||
nextObstacleId: 1,
|
||||
obstacles: [],
|
||||
playerVelocity: 0,
|
||||
playerY: 48,
|
||||
score: 0,
|
||||
status: 'ready',
|
||||
}
|
||||
}
|
||||
|
||||
export function flapSkyGlider(state: SkyFlappyGameState): SkyFlappyGameState {
|
||||
if (state.status === 'over' || state.status === 'paused') return state
|
||||
return { ...state, playerVelocity: FLAPPY_IMPULSE, status: 'playing' }
|
||||
}
|
||||
|
||||
function createObstacle(
|
||||
id: number,
|
||||
gapHeight: number,
|
||||
random: () => number,
|
||||
): SkyFlappyObstacle {
|
||||
return {
|
||||
gapHeight,
|
||||
gapTop:
|
||||
FLAPPY_MIN_GAP_TOP +
|
||||
Math.max(0, Math.min(1, random())) *
|
||||
(FLAPPY_MAX_GAP_TOP - FLAPPY_MIN_GAP_TOP),
|
||||
id,
|
||||
scored: false,
|
||||
x: 108,
|
||||
}
|
||||
}
|
||||
|
||||
function collidesWithObstacle(
|
||||
playerY: number,
|
||||
obstacle: SkyFlappyObstacle,
|
||||
): boolean {
|
||||
const horizontalCollision =
|
||||
FLAPPY_PLAYER_X + FLAPPY_PLAYER_RADIUS > obstacle.x &&
|
||||
FLAPPY_PLAYER_X - FLAPPY_PLAYER_RADIUS <
|
||||
obstacle.x + FLAPPY_OBSTACLE_WIDTH
|
||||
if (!horizontalCollision) return false
|
||||
|
||||
return (
|
||||
playerY - FLAPPY_PLAYER_RADIUS < obstacle.gapTop ||
|
||||
playerY + FLAPPY_PLAYER_RADIUS > obstacle.gapTop + obstacle.gapHeight
|
||||
)
|
||||
}
|
||||
|
||||
export function stepSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
elapsedSeconds: number,
|
||||
random: () => number = Math.random,
|
||||
): SkyFlappyGameState {
|
||||
if (state.status !== 'playing' || elapsedSeconds <= 0) return state
|
||||
|
||||
const difficulty = getSkyFlappyDifficulty(state.score)
|
||||
const playerVelocity = state.playerVelocity + FLAPPY_GRAVITY * elapsedSeconds
|
||||
const playerY = state.playerY + playerVelocity * elapsedSeconds
|
||||
let nextObstacleId = state.nextObstacleId
|
||||
let obstacles = state.obstacles
|
||||
.map((obstacle) => ({
|
||||
...obstacle,
|
||||
x: obstacle.x - difficulty.speed * elapsedSeconds,
|
||||
}))
|
||||
.filter((obstacle) => obstacle.x + FLAPPY_OBSTACLE_WIDTH > -2)
|
||||
|
||||
if (obstacles.length === 0 || obstacles[obstacles.length - 1].x < 61) {
|
||||
obstacles = [
|
||||
...obstacles,
|
||||
createObstacle(nextObstacleId, difficulty.gapHeight, random),
|
||||
]
|
||||
nextObstacleId += 1
|
||||
}
|
||||
|
||||
let score = state.score
|
||||
obstacles = obstacles.map((obstacle) => {
|
||||
if (
|
||||
!obstacle.scored &&
|
||||
obstacle.x + FLAPPY_OBSTACLE_WIDTH < FLAPPY_PLAYER_X
|
||||
) {
|
||||
score += 1
|
||||
return { ...obstacle, scored: true }
|
||||
}
|
||||
return obstacle
|
||||
})
|
||||
|
||||
const collided =
|
||||
playerY - FLAPPY_PLAYER_RADIUS <= 0 ||
|
||||
playerY + FLAPPY_PLAYER_RADIUS >= 100 ||
|
||||
obstacles.some((obstacle) => collidesWithObstacle(playerY, obstacle))
|
||||
|
||||
return {
|
||||
nextObstacleId,
|
||||
obstacles,
|
||||
playerVelocity,
|
||||
playerY,
|
||||
score,
|
||||
status: collided ? 'over' : 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
): SkyFlappyGameState {
|
||||
return state.status === 'playing' ? { ...state, status: 'paused' } : state
|
||||
}
|
||||
|
||||
export function resumeSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
): SkyFlappyGameState {
|
||||
return state.status === 'paused' ? { ...state, status: 'playing' } : state
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
createSkyFlappyGame,
|
||||
flapSkyGlider,
|
||||
pauseSkyFlappy,
|
||||
resumeSkyFlappy,
|
||||
stepSkyFlappy,
|
||||
} from './engine'
|
||||
import type { SkyFlappyDesign, SkyFlappyGameState } from './types'
|
||||
|
||||
type SkyFlappySave = {
|
||||
design: SkyFlappyDesign
|
||||
highScore: number
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function isDesign(value: unknown): value is SkyFlappyDesign {
|
||||
return value === 'dawn' || value === 'neon' || value === 'storm'
|
||||
}
|
||||
|
||||
export const useSkyFlappyStore = defineStore('sky-flappy', {
|
||||
state: () => ({
|
||||
design: 'dawn' as SkyFlappyDesign,
|
||||
game: null as SkyFlappyGameState | null,
|
||||
highScore: 0,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
const saved = useGamesStore().readGame<Partial<SkyFlappySave>>('sky-flappy')
|
||||
this.highScore = typeof saved?.highScore === 'number' && saved.highScore >= 0 ? Math.floor(saved.highScore) : 0
|
||||
this.design = isDesign(saved?.design) ? saved.design : 'dawn'
|
||||
if (typeof saved?.soundEnabled === 'boolean') this.soundEnabled = saved.soundEnabled
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('sky-flappy', {
|
||||
design: this.design,
|
||||
highScore: this.highScore,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies SkyFlappySave)
|
||||
},
|
||||
start(): void {
|
||||
this.game = createSkyFlappyGame()
|
||||
this.menuOpen = false
|
||||
},
|
||||
flap(): void {
|
||||
if (this.game) this.game = flapSkyGlider(this.game)
|
||||
},
|
||||
tick(elapsedSeconds: number): void {
|
||||
if (!this.game) return
|
||||
this.game = stepSkyFlappy(this.game, elapsedSeconds)
|
||||
if (this.game.status === 'over' && this.game.score > this.highScore) {
|
||||
this.highScore = this.game.score
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game) this.game = pauseSkyFlappy(this.game)
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game) {
|
||||
this.game = resumeSkyFlappy(this.game)
|
||||
this.menuOpen = false
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.menuOpen = true
|
||||
},
|
||||
setDesign(design: SkyFlappyDesign): void {
|
||||
this.design = design
|
||||
this.persist()
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SkyFlappyStatus = 'over' | 'paused' | 'playing' | 'ready'
|
||||
export type SkyFlappyDesign = 'dawn' | 'neon' | 'storm'
|
||||
|
||||
export type SkyFlappyObstacle = {
|
||||
gapHeight: number
|
||||
gapTop: number
|
||||
id: number
|
||||
scored: boolean
|
||||
x: number
|
||||
}
|
||||
|
||||
export type SkyFlappyGameState = {
|
||||
nextObstacleId: number
|
||||
obstacles: SkyFlappyObstacle[]
|
||||
playerVelocity: number
|
||||
playerY: number
|
||||
score: number
|
||||
status: SkyFlappyStatus
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSnakeGame,
|
||||
SNAKE_BOARD_WIDTH,
|
||||
stepSnake,
|
||||
turnSnake,
|
||||
} from './engine'
|
||||
import type { SnakeGameState } from './types'
|
||||
|
||||
describe('Snake engine', () => {
|
||||
it('moves one cell without changing its length', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
const next = stepSnake(game, () => 0)
|
||||
|
||||
expect(next.body[0]).toEqual({ x: game.body[0].x + 1, y: game.body[0].y })
|
||||
expect(next.body).toHaveLength(game.body.length)
|
||||
})
|
||||
|
||||
it('grows, scores, and places fruit outside the body', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
game.fruit = { x: game.body[0].x + 1, y: game.body[0].y }
|
||||
const next = stepSnake(game, () => 0)
|
||||
|
||||
expect(next.score).toBe(1)
|
||||
expect(next.body).toHaveLength(game.body.length + 1)
|
||||
expect(next.body).not.toContainEqual(next.fruit)
|
||||
})
|
||||
|
||||
it('ignores an immediate reverse direction', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
|
||||
expect(turnSnake(game, 'left')).toBe(game)
|
||||
expect(turnSnake(game, 'up').pendingDirection).toBe('up')
|
||||
})
|
||||
|
||||
it('ends the game on wall collision', () => {
|
||||
const game: SnakeGameState = {
|
||||
...createSnakeGame(() => 0),
|
||||
body: [
|
||||
{ x: SNAKE_BOARD_WIDTH - 1, y: 4 },
|
||||
{ x: SNAKE_BOARD_WIDTH - 2, y: 4 },
|
||||
],
|
||||
}
|
||||
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('ends the game on body collision', () => {
|
||||
const game: SnakeGameState = {
|
||||
body: [
|
||||
{ x: 4, y: 4 },
|
||||
{ x: 4, y: 3 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 3, y: 4 },
|
||||
{ x: 3, y: 5 },
|
||||
],
|
||||
direction: 'up',
|
||||
fruit: { x: 10, y: 10 },
|
||||
pendingDirection: 'left',
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('does not advance a paused game', () => {
|
||||
const game: SnakeGameState = {
|
||||
...createSnakeGame(() => 0),
|
||||
status: 'paused',
|
||||
}
|
||||
|
||||
expect(stepSnake(game)).toBe(game)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
SnakeDirection,
|
||||
SnakeGameState,
|
||||
SnakePoint,
|
||||
} from './types'
|
||||
|
||||
export const SNAKE_BOARD_WIDTH = 16
|
||||
export const SNAKE_BOARD_HEIGHT = 18
|
||||
|
||||
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
|
||||
up: { x: 0, y: -1 },
|
||||
right: { x: 1, y: 0 },
|
||||
down: { x: 0, y: 1 },
|
||||
left: { x: -1, y: 0 },
|
||||
}
|
||||
|
||||
const OPPOSITE_DIRECTIONS: Record<SnakeDirection, SnakeDirection> = {
|
||||
up: 'down',
|
||||
right: 'left',
|
||||
down: 'up',
|
||||
left: 'right',
|
||||
}
|
||||
|
||||
function pointsMatch(first: SnakePoint, second: SnakePoint): boolean {
|
||||
return first.x === second.x && first.y === second.y
|
||||
}
|
||||
|
||||
function placeFruit(
|
||||
body: SnakePoint[],
|
||||
random: () => number,
|
||||
): SnakePoint {
|
||||
const openCells: SnakePoint[] = []
|
||||
|
||||
for (let y = 0; y < SNAKE_BOARD_HEIGHT; y += 1) {
|
||||
for (let x = 0; x < SNAKE_BOARD_WIDTH; x += 1) {
|
||||
const point = { x, y }
|
||||
if (!body.some((segment) => pointsMatch(segment, point))) {
|
||||
openCells.push(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const index = Math.min(
|
||||
openCells.length - 1,
|
||||
Math.floor(Math.max(0, random()) * openCells.length),
|
||||
)
|
||||
return openCells[index] ?? body[0]
|
||||
}
|
||||
|
||||
export function createSnakeGame(random: () => number = Math.random): SnakeGameState {
|
||||
const centerX = Math.floor(SNAKE_BOARD_WIDTH / 2)
|
||||
const centerY = Math.floor(SNAKE_BOARD_HEIGHT / 2)
|
||||
const body = [
|
||||
{ x: centerX, y: centerY },
|
||||
{ x: centerX - 1, y: centerY },
|
||||
{ x: centerX - 2, y: centerY },
|
||||
]
|
||||
|
||||
return {
|
||||
body,
|
||||
direction: 'right',
|
||||
fruit: placeFruit(body, random),
|
||||
pendingDirection: 'right',
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function turnSnake(
|
||||
state: SnakeGameState,
|
||||
direction: SnakeDirection,
|
||||
): SnakeGameState {
|
||||
if (
|
||||
state.status !== 'playing' ||
|
||||
direction === OPPOSITE_DIRECTIONS[state.direction]
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { ...state, pendingDirection: direction }
|
||||
}
|
||||
|
||||
export function stepSnake(
|
||||
state: SnakeGameState,
|
||||
random: () => number = Math.random,
|
||||
): SnakeGameState {
|
||||
if (state.status !== 'playing') return state
|
||||
|
||||
const direction = state.pendingDirection
|
||||
const vector = DIRECTION_VECTORS[direction]
|
||||
const head = state.body[0]
|
||||
const nextHead = { x: head.x + vector.x, y: head.y + vector.y }
|
||||
const ateFruit = pointsMatch(nextHead, state.fruit)
|
||||
const collisionBody = ateFruit ? state.body : state.body.slice(0, -1)
|
||||
const hitWall =
|
||||
nextHead.x < 0 ||
|
||||
nextHead.x >= SNAKE_BOARD_WIDTH ||
|
||||
nextHead.y < 0 ||
|
||||
nextHead.y >= SNAKE_BOARD_HEIGHT
|
||||
const hitBody = collisionBody.some((segment) =>
|
||||
pointsMatch(segment, nextHead),
|
||||
)
|
||||
|
||||
if (hitWall || hitBody) {
|
||||
return { ...state, direction, status: 'game-over' }
|
||||
}
|
||||
|
||||
const body = [nextHead, ...state.body]
|
||||
if (!ateFruit) body.pop()
|
||||
|
||||
return {
|
||||
...state,
|
||||
body,
|
||||
direction,
|
||||
fruit: ateFruit ? placeFruit(body, random) : state.fruit,
|
||||
score: state.score + (ateFruit ? 1 : 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import { createSnakeGame, stepSnake, turnSnake } from './engine'
|
||||
import type { SnakeDirection, SnakeGameState, SnakeSpeed } from './types'
|
||||
|
||||
type SnakeSave = {
|
||||
highScore: number
|
||||
speed: SnakeSpeed
|
||||
}
|
||||
|
||||
const SPEED_TICK_MS: Record<SnakeSpeed, number> = {
|
||||
relaxed: 210,
|
||||
normal: 155,
|
||||
fast: 110,
|
||||
}
|
||||
|
||||
function isSnakeSpeed(value: unknown): value is SnakeSpeed {
|
||||
return value === 'relaxed' || value === 'normal' || value === 'fast'
|
||||
}
|
||||
|
||||
export const useSnakeStore = defineStore('snake', {
|
||||
state: () => ({
|
||||
game: null as SnakeGameState | null,
|
||||
highScore: 0,
|
||||
hydrated: false,
|
||||
speed: 'normal' as SnakeSpeed,
|
||||
}),
|
||||
getters: {
|
||||
tickMilliseconds: (state) => SPEED_TICK_MS[state.speed],
|
||||
},
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<SnakeSave>>('snake')
|
||||
this.highScore =
|
||||
typeof saved?.highScore === 'number' && saved.highScore >= 0
|
||||
? Math.floor(saved.highScore)
|
||||
: 0
|
||||
this.speed = isSnakeSpeed(saved?.speed) ? saved.speed : 'normal'
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('snake', {
|
||||
highScore: this.highScore,
|
||||
speed: this.speed,
|
||||
} satisfies SnakeSave)
|
||||
},
|
||||
setSpeed(speed: SnakeSpeed): void {
|
||||
this.speed = speed
|
||||
this.persist()
|
||||
},
|
||||
start(): void {
|
||||
this.game = createSnakeGame()
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game?.status === 'playing') {
|
||||
this.game = { ...this.game, status: 'paused' }
|
||||
}
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game?.status === 'paused') {
|
||||
this.game = { ...this.game, status: 'playing' }
|
||||
}
|
||||
},
|
||||
turn(direction: SnakeDirection): void {
|
||||
if (this.game) this.game = turnSnake(this.game, direction)
|
||||
},
|
||||
tick(): void {
|
||||
if (!this.game) return
|
||||
|
||||
this.game = stepSnake(this.game)
|
||||
if (this.game.status === 'game-over' && this.game.score > this.highScore) {
|
||||
this.highScore = this.game.score
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.game = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SnakeDirection = 'up' | 'right' | 'down' | 'left'
|
||||
|
||||
export type SnakePoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type SnakeSpeed = 'relaxed' | 'normal' | 'fast'
|
||||
|
||||
export type SnakeGameStatus = 'playing' | 'paused' | 'game-over'
|
||||
|
||||
export type SnakeGameState = {
|
||||
body: SnakePoint[]
|
||||
direction: SnakeDirection
|
||||
fruit: SnakePoint
|
||||
pendingDirection: SnakeDirection
|
||||
score: number
|
||||
status: SnakeGameStatus
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
|
||||
export const useGamesStore = defineStore('games', {
|
||||
state: () => ({
|
||||
games: {} as Record<string, unknown>,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(payload: unknown): void {
|
||||
this.games =
|
||||
payload && typeof payload === 'object'
|
||||
? cloneJsonData(payload as Record<string, unknown>)
|
||||
: {}
|
||||
},
|
||||
readGame<T>(gameId: string): T | undefined {
|
||||
return this.games[gameId] as T | undefined
|
||||
},
|
||||
saveGame(gameId: string, payload: unknown): void {
|
||||
this.games[gameId] = cloneJsonData(payload)
|
||||
usePhoneStore().saveDeviceNamespace('games', this.games)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
||||
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
||||
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
||||
import startUrl from '@/assets/audio/tower-stack/start.wav?url'
|
||||
|
||||
export type TowerStackSound = 'fall' | 'hit' | 'perfect' | 'start'
|
||||
|
||||
const soundUrls: Record<TowerStackSound, string> = {
|
||||
fall: fallUrl,
|
||||
hit: hitUrl,
|
||||
perfect: perfectUrl,
|
||||
start: startUrl,
|
||||
}
|
||||
const playerPools = new Map<TowerStackSound, HTMLAudioElement[]>()
|
||||
|
||||
function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
|
||||
const existing = playerPools.get(sound)
|
||||
if (existing) return existing
|
||||
|
||||
const players = Array.from({ length: 3 }, () => {
|
||||
const player = new Audio(soundUrls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
})
|
||||
playerPools.set(sound, players)
|
||||
return players
|
||||
}
|
||||
|
||||
export function playTowerStackSound(
|
||||
sound: TowerStackSound,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (!enabled) return
|
||||
|
||||
const players = getPlayers(sound)
|
||||
const player = players.find((candidate) => candidate.paused) ?? players[0]
|
||||
player.currentTime = 0
|
||||
void player.play().catch((error: unknown) => {
|
||||
console.error(`[Tower Stack audio] Failed to play ${sound}`, error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
advanceTowerBlock,
|
||||
createTowerGame,
|
||||
placeTowerBlock,
|
||||
TOWER_BASE_WIDTH,
|
||||
TOWER_MAX_SPEED,
|
||||
TOWER_PERFECT_TOLERANCE,
|
||||
} from './engine'
|
||||
|
||||
describe('tower stack engine', () => {
|
||||
it('creates a centered foundation and moving block', () => {
|
||||
const state = createTowerGame()
|
||||
expect(state.blocks).toHaveLength(1)
|
||||
expect(state.blocks[0].width).toBe(TOWER_BASE_WIDTH)
|
||||
expect(state.active?.x).toBe(0)
|
||||
expect(state.status).toBe('playing')
|
||||
})
|
||||
|
||||
it('moves the active block using elapsed time', () => {
|
||||
const state = createTowerGame()
|
||||
const moved = advanceTowerBlock(state, 0.5)
|
||||
expect(moved.active?.x).toBeCloseTo(11.5)
|
||||
})
|
||||
|
||||
it('reflects a moving block at the field edge', () => {
|
||||
const state = createTowerGame()
|
||||
const moved = advanceTowerBlock(state, 2)
|
||||
expect(moved.active?.direction).toBe(-1)
|
||||
expect(moved.active?.x).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('keeps only the overlapping part', () => {
|
||||
const state = createTowerGame()
|
||||
state.active = { ...state.active!, x: 25 }
|
||||
const result = placeTowerBlock(state)
|
||||
expect(result.outcome).toBe('placed')
|
||||
expect(result.state.blocks.at(-1)?.width).toBeCloseTo(59)
|
||||
expect(result.cutWidth).toBeCloseTo(9)
|
||||
})
|
||||
|
||||
it('rewards a perfect placement within tolerance', () => {
|
||||
const state = createTowerGame()
|
||||
state.active = {
|
||||
...state.active!,
|
||||
x: state.blocks[0].x + TOWER_PERFECT_TOLERANCE / 2,
|
||||
}
|
||||
const result = placeTowerBlock(state)
|
||||
expect(result.outcome).toBe('perfect')
|
||||
expect(result.state.perfects).toBe(1)
|
||||
expect(result.state.score).toBe(4)
|
||||
})
|
||||
|
||||
it('ends the round on a complete miss', () => {
|
||||
const state = createTowerGame()
|
||||
state.active = { ...state.active!, width: 8, x: 0 }
|
||||
const result = placeTowerBlock(state)
|
||||
expect(result.outcome).toBe('missed')
|
||||
expect(result.state.status).toBe('over')
|
||||
expect(result.state.active).toBeNull()
|
||||
})
|
||||
|
||||
it('alternates movement direction for each level', () => {
|
||||
const state = createTowerGame()
|
||||
state.active = { ...state.active!, x: state.blocks[0].x }
|
||||
const result = placeTowerBlock(state)
|
||||
expect(result.state.active?.direction).toBe(-1)
|
||||
})
|
||||
|
||||
it('caps speed at the configured maximum', () => {
|
||||
let state = createTowerGame()
|
||||
for (let level = 0; level < 30; level += 1) {
|
||||
state.active = { ...state.active!, x: state.blocks.at(-1)!.x }
|
||||
state = placeTowerBlock(state).state
|
||||
}
|
||||
expect(state.active?.speed).toBe(TOWER_MAX_SPEED)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
TowerActiveBlock,
|
||||
TowerBlock,
|
||||
TowerGameState,
|
||||
TowerPlacement,
|
||||
} from './types'
|
||||
|
||||
export const TOWER_FIELD_WIDTH = 100
|
||||
export const TOWER_BASE_WIDTH = 68
|
||||
export const TOWER_PERFECT_TOLERANCE = 1.35
|
||||
export const TOWER_START_SPEED = 23
|
||||
export const TOWER_MAX_SPEED = 48
|
||||
|
||||
function speedForLevel(level: number): number {
|
||||
return Math.min(
|
||||
TOWER_MAX_SPEED,
|
||||
TOWER_START_SPEED + Math.max(0, level - 1) * 1.65,
|
||||
)
|
||||
}
|
||||
|
||||
function createActiveBlock(
|
||||
level: number,
|
||||
width: number,
|
||||
direction: -1 | 1,
|
||||
): TowerActiveBlock {
|
||||
return {
|
||||
colorIndex: level % 6,
|
||||
direction,
|
||||
id: level,
|
||||
level,
|
||||
speed: speedForLevel(level),
|
||||
width,
|
||||
x: direction === 1 ? 0 : TOWER_FIELD_WIDTH - width,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTowerGame(): TowerGameState {
|
||||
const base: TowerBlock = {
|
||||
colorIndex: 0,
|
||||
id: 0,
|
||||
level: 0,
|
||||
width: TOWER_BASE_WIDTH,
|
||||
x: (TOWER_FIELD_WIDTH - TOWER_BASE_WIDTH) / 2,
|
||||
}
|
||||
|
||||
return {
|
||||
active: createActiveBlock(1, TOWER_BASE_WIDTH, 1),
|
||||
blocks: [base],
|
||||
perfects: 0,
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceTowerBlock(
|
||||
state: TowerGameState,
|
||||
elapsedSeconds: number,
|
||||
): TowerGameState {
|
||||
if (state.status !== 'playing' || !state.active || elapsedSeconds <= 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const active = { ...state.active }
|
||||
let nextX = active.x + active.direction * active.speed * elapsedSeconds
|
||||
const maximumX = TOWER_FIELD_WIDTH - active.width
|
||||
|
||||
while (nextX < 0 || nextX > maximumX) {
|
||||
if (nextX > maximumX) {
|
||||
nextX = maximumX - (nextX - maximumX)
|
||||
active.direction = -1
|
||||
} else {
|
||||
nextX = -nextX
|
||||
active.direction = 1
|
||||
}
|
||||
}
|
||||
|
||||
active.x = nextX
|
||||
return { ...state, active }
|
||||
}
|
||||
|
||||
export function placeTowerBlock(state: TowerGameState): TowerPlacement {
|
||||
if (state.status !== 'playing' || !state.active) {
|
||||
return { cutSide: null, cutWidth: 0, outcome: 'missed', state }
|
||||
}
|
||||
|
||||
const active = state.active
|
||||
const support = state.blocks[state.blocks.length - 1]
|
||||
const overlapStart = Math.max(active.x, support.x)
|
||||
const overlapEnd = Math.min(active.x + active.width, support.x + support.width)
|
||||
const overlapWidth = overlapEnd - overlapStart
|
||||
|
||||
if (overlapWidth <= 0) {
|
||||
return {
|
||||
cutSide: active.x < support.x ? 'left' : 'right',
|
||||
cutWidth: active.width,
|
||||
outcome: 'missed',
|
||||
state: { ...state, active: null, status: 'over' },
|
||||
}
|
||||
}
|
||||
|
||||
const offset = active.x - support.x
|
||||
const isPerfect = Math.abs(offset) <= TOWER_PERFECT_TOLERANCE
|
||||
const placedWidth = isPerfect
|
||||
? Math.min(TOWER_BASE_WIDTH, support.width + 0.85)
|
||||
: overlapWidth
|
||||
const placedX = isPerfect
|
||||
? support.x - (placedWidth - support.width) / 2
|
||||
: overlapStart
|
||||
const block: TowerBlock = {
|
||||
colorIndex: active.colorIndex,
|
||||
id: active.id,
|
||||
level: active.level,
|
||||
width: placedWidth,
|
||||
x: placedX,
|
||||
}
|
||||
const nextLevel = active.level + 1
|
||||
const direction: -1 | 1 = nextLevel % 2 === 0 ? -1 : 1
|
||||
|
||||
return {
|
||||
cutSide: isPerfect ? null : offset < 0 ? 'left' : 'right',
|
||||
cutWidth: isPerfect ? 0 : active.width - overlapWidth,
|
||||
outcome: isPerfect ? 'perfect' : 'placed',
|
||||
state: {
|
||||
active: createActiveBlock(nextLevel, placedWidth, direction),
|
||||
blocks: [...state.blocks, block],
|
||||
perfects: state.perfects + (isPerfect ? 1 : 0),
|
||||
score: state.score + 1 + (isPerfect ? 3 : 0),
|
||||
status: 'playing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseTowerGame(state: TowerGameState): TowerGameState {
|
||||
return state.status === 'playing' ? { ...state, status: 'paused' } : state
|
||||
}
|
||||
|
||||
export function resumeTowerGame(state: TowerGameState): TowerGameState {
|
||||
return state.status === 'paused' ? { ...state, status: 'playing' } : state
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
advanceTowerBlock,
|
||||
createTowerGame,
|
||||
pauseTowerGame,
|
||||
placeTowerBlock,
|
||||
resumeTowerGame,
|
||||
} from './engine'
|
||||
import type { TowerGameState, TowerPlacement } from './types'
|
||||
|
||||
type TowerStackSave = {
|
||||
highHeight: number
|
||||
highScore: number
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
export const useTowerStackStore = defineStore('tower-stack', {
|
||||
state: () => ({
|
||||
game: null as TowerGameState | null,
|
||||
highHeight: 0,
|
||||
highScore: 0,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<TowerStackSave>>(
|
||||
'tower-stack',
|
||||
)
|
||||
this.highHeight =
|
||||
typeof saved?.highHeight === 'number' && saved.highHeight >= 0
|
||||
? Math.floor(saved.highHeight)
|
||||
: 0
|
||||
this.highScore =
|
||||
typeof saved?.highScore === 'number' && saved.highScore >= 0
|
||||
? Math.floor(saved.highScore)
|
||||
: 0
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('tower-stack', {
|
||||
highHeight: this.highHeight,
|
||||
highScore: this.highScore,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies TowerStackSave)
|
||||
},
|
||||
start(): void {
|
||||
this.game = createTowerGame()
|
||||
this.menuOpen = false
|
||||
},
|
||||
tick(elapsedSeconds: number): void {
|
||||
if (this.game) {
|
||||
this.game = advanceTowerBlock(this.game, elapsedSeconds)
|
||||
}
|
||||
},
|
||||
place(): TowerPlacement | null {
|
||||
if (!this.game) return null
|
||||
|
||||
const result = placeTowerBlock(this.game)
|
||||
this.game = result.state
|
||||
if (result.state.status === 'over') {
|
||||
const height = result.state.blocks.length - 1
|
||||
this.highHeight = Math.max(this.highHeight, height)
|
||||
this.highScore = Math.max(this.highScore, result.state.score)
|
||||
this.persist()
|
||||
}
|
||||
return result
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game) this.game = pauseTowerGame(this.game)
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game) {
|
||||
this.game = resumeTowerGame(this.game)
|
||||
this.menuOpen = false
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.menuOpen = true
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export type TowerStatus = 'over' | 'paused' | 'playing'
|
||||
|
||||
export type TowerBlock = {
|
||||
colorIndex: number
|
||||
id: number
|
||||
level: number
|
||||
width: number
|
||||
x: number
|
||||
}
|
||||
|
||||
export type TowerActiveBlock = TowerBlock & {
|
||||
direction: -1 | 1
|
||||
speed: number
|
||||
}
|
||||
|
||||
export type TowerGameState = {
|
||||
active: TowerActiveBlock | null
|
||||
blocks: TowerBlock[]
|
||||
perfects: number
|
||||
score: number
|
||||
status: TowerStatus
|
||||
}
|
||||
|
||||
export type TowerPlacement = {
|
||||
cutSide: 'left' | 'right' | null
|
||||
cutWidth: number
|
||||
outcome: 'missed' | 'perfect' | 'placed'
|
||||
state: TowerGameState
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as string[],
|
||||
}),
|
||||
actions: {
|
||||
claimApp(id: string): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
const data = payload as { claimedApps?: unknown } | null
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is string => typeof id === 'string')
|
||||
: []
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('apps', {
|
||||
claimedApps: this.claimedApps,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCalendarStore } from '@/stores/calendar'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('calendar store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads a bounded calendar range', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: [], success: true })
|
||||
const calendar = useCalendarStore()
|
||||
|
||||
expect(await calendar.load(1_000_000, 2_000_000)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('calendar:list', {
|
||||
endsAt: 2000,
|
||||
startsAt: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends server timestamps and revisions when updating', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const calendar = useCalendarStore()
|
||||
|
||||
await calendar.update(
|
||||
{
|
||||
endsAt: 0,
|
||||
id: 'event-id',
|
||||
note: '',
|
||||
remindedAt: null,
|
||||
reminderMinutes: null,
|
||||
revision: 4,
|
||||
startsAt: 0,
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
endsAt: 2_000_000,
|
||||
note: 'Bring documents',
|
||||
reminderMinutes: 60,
|
||||
startsAt: 1_000_000,
|
||||
title: 'Meeting',
|
||||
},
|
||||
)
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('calendar:update', {
|
||||
endsAt: 2000,
|
||||
id: 'event-id',
|
||||
note: 'Bring documents',
|
||||
reminderMinutes: 60,
|
||||
revision: 4,
|
||||
startsAt: 1000,
|
||||
title: 'Meeting',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
function toServerDraft(draft: CalendarEventDraft): Record<string, unknown> {
|
||||
return {
|
||||
endsAt: Math.floor(draft.endsAt / 1000),
|
||||
note: draft.note,
|
||||
reminderMinutes: draft.reminderMinutes,
|
||||
startsAt: Math.floor(draft.startsAt / 1000),
|
||||
title: draft.title,
|
||||
}
|
||||
}
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', {
|
||||
state: () => ({
|
||||
error: '' as string,
|
||||
events: [] as CalendarEvent[],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async create(draft: CalendarEventDraft): Promise<boolean> {
|
||||
const response = await nuiCall<{ id: string }>(
|
||||
'calendar:create',
|
||||
toServerDraft(draft),
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
return response.success
|
||||
},
|
||||
async deleteEvent(id: string): Promise<boolean> {
|
||||
const response = await nuiCall('calendar:delete', { id })
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) {
|
||||
this.events = this.events.filter((event) => event.id !== id)
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async load(startsAt: number, endsAt: number): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<CalendarEvent[]>('calendar:list', {
|
||||
endsAt: Math.floor(endsAt / 1000),
|
||||
startsAt: Math.floor(startsAt / 1000),
|
||||
})
|
||||
this.loading = false
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) this.events = response.data ?? []
|
||||
return response.success
|
||||
},
|
||||
async update(
|
||||
event: CalendarEvent,
|
||||
draft: CalendarEventDraft,
|
||||
): Promise<boolean> {
|
||||
const response = await nuiCall('calendar:update', {
|
||||
...toServerDraft(draft),
|
||||
id: event.id,
|
||||
revision: event.revision,
|
||||
})
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
return response.success
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
parseAlarms,
|
||||
} from '@/utils/alarms'
|
||||
import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export const useClockStore = defineStore('clock', {
|
||||
state: () => ({
|
||||
alarms: structuredClone(DEFAULT_ALARMS),
|
||||
alarms: cloneJsonData(DEFAULT_ALARMS),
|
||||
laps: [] as number[],
|
||||
stopwatchAccumulated: 0,
|
||||
stopwatchStartedAt: null as number | null,
|
||||
@@ -27,7 +28,7 @@ export const useClockStore = defineStore('clock', {
|
||||
actions: {
|
||||
createAlarm(draft: AlarmDraft): Alarm {
|
||||
const alarm: Alarm = {
|
||||
...structuredClone(draft),
|
||||
...cloneJsonData(draft),
|
||||
enabled: true,
|
||||
id: `alarm-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
lastTriggeredMinute: null,
|
||||
@@ -144,7 +145,7 @@ export const useClockStore = defineStore('clock', {
|
||||
updateAlarm(id: string, draft: AlarmDraft): void {
|
||||
const alarm = this.alarms.find((candidate) => candidate.id === id)
|
||||
if (!alarm) return
|
||||
Object.assign(alarm, structuredClone(draft), {
|
||||
Object.assign(alarm, cloneJsonData(draft), {
|
||||
lastTriggeredMinute: null,
|
||||
})
|
||||
this.persistAlarms()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('marketplace store offers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('sends an offer and refreshes conversations and counts', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: { id: 7 }, success: true })
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: { active: 1, unread: 0 }, success: true })
|
||||
|
||||
const marketplace = useMarketplaceStore()
|
||||
const response = await marketplace.makeOffer('inquiry-id', 175000)
|
||||
|
||||
expect(response).toEqual({ data: { id: 7 }, success: true })
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'marketplace:make-offer', {
|
||||
amount: 175000,
|
||||
inquiryId: 'inquiry-id',
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:list-inquiries')
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:counts')
|
||||
})
|
||||
|
||||
it('does not refresh state when an offer response is rejected by the server', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ error: 'offer_conflict', success: false })
|
||||
|
||||
const marketplace = useMarketplaceStore()
|
||||
const response = await marketplace.respondOffer('inquiry-id', 'accepted')
|
||||
|
||||
expect(response).toEqual({ error: 'offer_conflict', success: false })
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:respond-offer', {
|
||||
action: 'accepted',
|
||||
inquiryId: 'inquiry-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
MarketplaceChat,
|
||||
MarketplaceCounts,
|
||||
MarketplaceInquirySummary,
|
||||
MarketplaceListing,
|
||||
MarketplaceListingDraft,
|
||||
MarketplaceListingSummary,
|
||||
} from '@/types/marketplace'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
type ListingPage = {
|
||||
hasMore: boolean
|
||||
items: MarketplaceListingSummary[]
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const useMarketplaceStore = defineStore('marketplace', {
|
||||
state: () => ({
|
||||
counts: { active: 0, unread: 0 } as MarketplaceCounts,
|
||||
inquiries: [] as MarketplaceInquirySummary[],
|
||||
isLoading: false,
|
||||
items: [] as MarketplaceListingSummary[],
|
||||
ownItems: [] as MarketplaceListingSummary[],
|
||||
}),
|
||||
actions: {
|
||||
async load(filters: Record<string, unknown> = {}): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<ListingPage>('marketplace:list', filters)
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) this.items = response.data.items
|
||||
return response.success
|
||||
},
|
||||
async get(id: string): Promise<NuiResponse<MarketplaceListing>> {
|
||||
return nuiCall<MarketplaceListing>('marketplace:get', { id })
|
||||
},
|
||||
async loadOwn(): Promise<boolean> {
|
||||
const response = await nuiCall<ListingPage>('marketplace:list-own')
|
||||
if (response.success && response.data) this.ownItems = response.data.items
|
||||
return response.success
|
||||
},
|
||||
async loadCounts(): Promise<void> {
|
||||
const response = await nuiCall<MarketplaceCounts>('marketplace:counts')
|
||||
if (response.success && response.data) this.counts = response.data
|
||||
},
|
||||
setCounts(counts: MarketplaceCounts): void {
|
||||
this.counts = counts
|
||||
},
|
||||
async create(draft: MarketplaceListingDraft): Promise<NuiResponse<{ id: string }>> {
|
||||
const response = await nuiCall<{ id: string }>('marketplace:create', draft)
|
||||
if (response.success) await Promise.all([this.load(), this.loadOwn(), this.loadCounts()])
|
||||
return response
|
||||
},
|
||||
async update(
|
||||
id: string,
|
||||
revision: number,
|
||||
draft: MarketplaceListingDraft,
|
||||
): Promise<NuiResponse<{ revision: number }>> {
|
||||
const response = await nuiCall<{ revision: number }>('marketplace:update', {
|
||||
...draft,
|
||||
id,
|
||||
revision,
|
||||
})
|
||||
if (response.success) await Promise.all([this.load(), this.loadOwn()])
|
||||
return response
|
||||
},
|
||||
async setStatus(id: string, status: string, inquiryId?: string): Promise<boolean> {
|
||||
const response = await nuiCall('marketplace:set-status', {
|
||||
id,
|
||||
inquiryId,
|
||||
status,
|
||||
})
|
||||
if (response.success) await Promise.all([this.load(), this.loadOwn(), this.loadCounts()])
|
||||
return response.success
|
||||
},
|
||||
async favorite(id: string, favorite: boolean): Promise<boolean> {
|
||||
const response = await nuiCall('marketplace:favorite', { favorite, id })
|
||||
if (response.success) {
|
||||
for (const item of [...this.items, ...this.ownItems]) {
|
||||
if (item.id === id) item.is_favorite = favorite
|
||||
}
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async loadInquiries(): Promise<boolean> {
|
||||
const response = await nuiCall<MarketplaceInquirySummary[]>('marketplace:list-inquiries')
|
||||
if (response.success && response.data) this.inquiries = response.data
|
||||
return response.success
|
||||
},
|
||||
async getInquiry(id: string): Promise<NuiResponse<MarketplaceChat>> {
|
||||
return nuiCall<MarketplaceChat>('marketplace:get-inquiry', { id })
|
||||
},
|
||||
async sendMessage(payload: {
|
||||
body: string
|
||||
inquiryId?: string
|
||||
listingId?: string
|
||||
}): Promise<NuiResponse<{ id: string }>> {
|
||||
const response = await nuiCall<{ id: string }>('marketplace:send-message', payload)
|
||||
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
|
||||
return response
|
||||
},
|
||||
async makeOffer(
|
||||
inquiryId: string,
|
||||
amount: number,
|
||||
): Promise<NuiResponse<{ id: number }>> {
|
||||
const response = await nuiCall<{ id: number }>('marketplace:make-offer', {
|
||||
amount,
|
||||
inquiryId,
|
||||
})
|
||||
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
|
||||
return response
|
||||
},
|
||||
async respondOffer(
|
||||
inquiryId: string,
|
||||
action: 'accepted' | 'rejected',
|
||||
): Promise<NuiResponse> {
|
||||
const response = await nuiCall('marketplace:respond-offer', { action, inquiryId })
|
||||
if (response.success) await Promise.all([this.loadInquiries(), this.loadCounts()])
|
||||
return response
|
||||
},
|
||||
report(id: string, reason: string, details = ''): Promise<NuiResponse> {
|
||||
return nuiCall('marketplace:report', { details, id, reason })
|
||||
},
|
||||
block(listingId: string, blocked = true): Promise<NuiResponse> {
|
||||
return nuiCall('marketplace:block', { blocked, listingId })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
saveDeviceNamespace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({ saveDeviceNamespace: mocks.saveDeviceNamespace }),
|
||||
}))
|
||||
|
||||
describe('media store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
mocks.saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
it('returns each captured photo and persists it in the shared gallery', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456789)
|
||||
const media = useMediaStore()
|
||||
|
||||
const first = media.capture()
|
||||
const second = media.capture()
|
||||
|
||||
expect(first.id).toBe('capture-123456789-1')
|
||||
expect(second.id).toBe('capture-123456789-2')
|
||||
expect(media.photos.slice(0, 2)).toEqual([second, first])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('media', {
|
||||
captures: [second, first],
|
||||
claimedApps: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,25 @@ export const useMediaStore = defineStore('media', {
|
||||
photos: (state): PhonePhoto[] => [...state.captures, ...samplePhotos],
|
||||
},
|
||||
actions: {
|
||||
capture(): PhonePhoto {
|
||||
const gradients = [
|
||||
'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
|
||||
'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)',
|
||||
'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
|
||||
]
|
||||
const captureNumber = this.captures.length
|
||||
const id = `capture-${Date.now()}-${captureNumber + 1}`
|
||||
const photo: PhonePhoto = {
|
||||
attachmentId: id,
|
||||
capturedAt: Date.now(),
|
||||
gradient: gradients[captureNumber % gradients.length],
|
||||
id,
|
||||
titleKey: 'Apps.photos.samples.capture',
|
||||
}
|
||||
this.captures.unshift(photo)
|
||||
this.persist()
|
||||
return photo
|
||||
},
|
||||
claimApp(id: string): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { type Note, type NoteDraft } from '@/utils/notes'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
|
||||
export const useNotesStore = defineStore('notes', {
|
||||
state: () => ({
|
||||
@@ -29,7 +30,7 @@ export const useNotesStore = defineStore('notes', {
|
||||
})
|
||||
},
|
||||
hydrate(notes: Note[]): void {
|
||||
this.notes = structuredClone(notes)
|
||||
this.notes = cloneJsonData(notes)
|
||||
},
|
||||
async createRemote(note: Note): Promise<void> {
|
||||
const response = await nuiCall<Note[]>('notes:create', note)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppId } from '@/types/apps'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { PhonePreferencesV1 } from '@/utils/preferences'
|
||||
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
|
||||
|
||||
@@ -13,7 +13,7 @@ export type PhoneNotificationDevice = {
|
||||
}
|
||||
|
||||
export type PhoneNotificationInput = {
|
||||
appId: PhoneAppId
|
||||
appId: LaunchablePhoneAppId
|
||||
critical?: boolean
|
||||
device?: PhoneNotificationDevice
|
||||
persistent?: boolean
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('Local Pages store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('shares only the CityMarkt listing id with the server', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: { id: 'post-id' }, success: true })
|
||||
const response = await usePagesStore().shareCityMarkt('listing-id')
|
||||
expect(response.success).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('pages:share-citymarkt', {
|
||||
listingId: 'listing-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates a successful like locally', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const pages = usePagesStore()
|
||||
pages.items = [{ id: 'post-id', is_liked: false, like_count: 2 } as never]
|
||||
await pages.react('post-id', 'like', true)
|
||||
expect(pages.items[0]?.is_liked).toBe(true)
|
||||
expect(pages.items[0]?.like_count).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { PagesPage, PagesPost, PagesPostDraft } from '@/types/pages'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const usePagesStore = defineStore('pages', {
|
||||
state: () => ({
|
||||
items: [] as PagesPost[],
|
||||
ownItems: [] as PagesPost[],
|
||||
savedItems: [] as PagesPost[],
|
||||
isLoading: false,
|
||||
}),
|
||||
actions: {
|
||||
async load(filters: Record<string, unknown> = {}): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<PagesPage>('pages:list', filters)
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) this.items = response.data.items
|
||||
return response.success
|
||||
},
|
||||
async loadProfile(): Promise<boolean> {
|
||||
const [own, saved] = await Promise.all([
|
||||
nuiCall<PagesPage>('pages:list-own'),
|
||||
nuiCall<PagesPage>('pages:list', { saved: true }),
|
||||
])
|
||||
if (own.success && own.data) this.ownItems = own.data.items
|
||||
if (saved.success && saved.data) this.savedItems = saved.data.items
|
||||
return own.success && saved.success
|
||||
},
|
||||
get(id: string): Promise<NuiResponse<PagesPost>> {
|
||||
return nuiCall<PagesPost>('pages:get', { id })
|
||||
},
|
||||
async create(draft: PagesPostDraft): Promise<NuiResponse<{ id: string }>> {
|
||||
const response = await nuiCall<{ id: string }>('pages:create', draft)
|
||||
if (response.success) await Promise.all([this.load(), this.loadProfile()])
|
||||
return response
|
||||
},
|
||||
async shareCityMarkt(listingId: string): Promise<NuiResponse<{ id: string }>> {
|
||||
return nuiCall<{ id: string }>('pages:share-citymarkt', { listingId })
|
||||
},
|
||||
async react(id: string, kind: 'like' | 'save', active: boolean): Promise<boolean> {
|
||||
const response = await nuiCall('pages:react', { active, id, kind })
|
||||
if (response.success) {
|
||||
for (const item of [...this.items, ...this.ownItems, ...this.savedItems]) {
|
||||
if (item.id !== id) continue
|
||||
if (kind === 'like') {
|
||||
item.like_count = Math.max(0, item.like_count + (active ? 1 : -1))
|
||||
item.is_liked = active
|
||||
} else item.is_saved = active
|
||||
}
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const response = await nuiCall('pages:delete', { id })
|
||||
if (response.success) {
|
||||
this.items = this.items.filter((item) => item.id !== id)
|
||||
this.ownItems = this.ownItems.filter((item) => item.id !== id)
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AppLaunchOrigin, PhoneAppId } from '@/types/apps'
|
||||
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
@@ -211,6 +213,309 @@ const defaultLocales: LocaleTree = {
|
||||
},
|
||||
},
|
||||
calculator: { name: 'Calculator' },
|
||||
snake: {
|
||||
name: 'Snake',
|
||||
backToMenu: 'Back to game menu',
|
||||
board: 'Snake game board',
|
||||
controls: 'Direction controls',
|
||||
directions: {
|
||||
down: 'Move down',
|
||||
left: 'Move left',
|
||||
right: 'Move right',
|
||||
up: 'Move up',
|
||||
},
|
||||
gameOver: 'Game Over',
|
||||
highScore: 'High Score',
|
||||
menu: 'Main Menu',
|
||||
pause: 'Pause game',
|
||||
paused: 'Paused',
|
||||
readyBody: 'Collect fruit, grow longer, and stay clear of every wall.',
|
||||
readyTitle: 'Ready to play?',
|
||||
restart: 'Play Again',
|
||||
resume: 'Resume game',
|
||||
score: 'Score',
|
||||
speed: 'Speed',
|
||||
speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' },
|
||||
start: 'Start Game',
|
||||
swipeHint: 'Swipe, tap the controls, or use arrow keys / WASD',
|
||||
},
|
||||
memory: {
|
||||
name: 'Memory',
|
||||
backToMenu: 'Back to board selection',
|
||||
board: 'Memory card board',
|
||||
chooseBody: 'Find every matching pair with as few moves as possible.',
|
||||
chooseTitle: 'Choose a board',
|
||||
completeEyebrow: 'Board cleared',
|
||||
completeTitle: 'Great memory!',
|
||||
difficulties: { large: 'Expert', medium: 'Classic', small: 'Quick' },
|
||||
eyebrow: 'Matching game',
|
||||
hiddenCard: 'Hidden card',
|
||||
menu: 'Board Selection',
|
||||
moves: 'Moves',
|
||||
mute: 'Mute game sounds',
|
||||
noBest: 'No best score yet',
|
||||
playAgain: 'Play Again',
|
||||
restart: 'Restart',
|
||||
revealedCard: 'Revealed card',
|
||||
time: 'Time',
|
||||
unmute: 'Turn on game sounds',
|
||||
},
|
||||
numberMerge: {
|
||||
name: '2048',
|
||||
backToMenu: 'Back to game menu',
|
||||
best: 'Best',
|
||||
board: '2048 game board',
|
||||
cancel: 'Keep Current Game',
|
||||
confirmBody: 'Your current board will be replaced. This cannot be undone.',
|
||||
confirmNew: 'Start New Game',
|
||||
confirmTitle: 'Replace current game?',
|
||||
continueGame: 'Continue Game',
|
||||
controls: 'Move controls',
|
||||
directions: {
|
||||
down: 'Move tiles down',
|
||||
left: 'Move tiles left',
|
||||
right: 'Move tiles right',
|
||||
up: 'Move tiles up',
|
||||
},
|
||||
eyebrow: 'Number puzzle',
|
||||
gameOverBody: 'No empty spaces or matching neighbours remain.',
|
||||
gameOverTitle: 'No more moves',
|
||||
highest: 'Highest Tile',
|
||||
howToBody: 'Swipe to combine matching tiles. Each pair becomes one larger tile.',
|
||||
howToExample: '2 + 2 = 4 · 4 + 4 = 8 · … · 1024 + 1024 = 2048',
|
||||
howToGoal: 'Goal: create the 2048 tile before the board has no moves left.',
|
||||
howToTitle: 'How to play',
|
||||
keepPlaying: 'Keep Playing',
|
||||
mainMenu: 'Main Menu',
|
||||
menuBody: 'Slide matching numbers together and work your way up to 2048.',
|
||||
menuTitle: 'Build the 2048 tile',
|
||||
mute: 'Mute game sounds',
|
||||
newGame: 'New Game',
|
||||
score: 'Score',
|
||||
swipeHint: 'Swipe the board, use the buttons, or press arrow keys / WASD',
|
||||
unmute: 'Turn on game sounds',
|
||||
wonBody: 'You reached the legendary tile. Continue climbing or begin again.',
|
||||
wonTitle: 'You made 2048!',
|
||||
},
|
||||
minesweeper: {
|
||||
name: 'Minesweeper',
|
||||
backToMenu: 'Back to game menu',
|
||||
board: 'Minesweeper board',
|
||||
chooseBody:
|
||||
'Reveal every safe field. Numbers show how many mines touch that field.',
|
||||
chooseTitle: 'Choose a minefield',
|
||||
difficulties: { classic: 'Classic', expert: 'Expert', quick: 'Quick' },
|
||||
emptyCell: 'Revealed empty field',
|
||||
eyebrow: 'Mine puzzle',
|
||||
flaggedCell: 'Flagged field',
|
||||
gameHint: 'Tap to reveal · Hold or right-click to flag',
|
||||
hiddenCell: 'Hidden field',
|
||||
longPressHint: 'Tap to reveal · Hold to place a flag',
|
||||
lostBody:
|
||||
'A mine was hidden there. Mark suspicious fields and try again.',
|
||||
lostTitle: 'Mine triggered',
|
||||
mainMenu: 'Main Menu',
|
||||
mineCell: 'Revealed mine',
|
||||
mines: 'Mines',
|
||||
mute: 'Mute game sounds',
|
||||
noBest: 'No best time yet',
|
||||
numberCell: 'Revealed field with {count} adjacent mines',
|
||||
playAgain: 'Play Again',
|
||||
restart: 'Restart',
|
||||
resume: 'Continue Game',
|
||||
time: 'Time',
|
||||
unmute: 'Turn on game sounds',
|
||||
wonBody: 'Minefield cleared in {time}.',
|
||||
wonTitle: 'Field cleared!',
|
||||
},
|
||||
towerStack: {
|
||||
name: 'Tower Stack',
|
||||
backToMenu: 'Back to game menu',
|
||||
bestHeight: 'Best Height',
|
||||
bestScore: 'Best Score',
|
||||
blocks: 'Blocks',
|
||||
eyebrow: 'Timing challenge',
|
||||
finalScore: 'Final Score',
|
||||
gameHint: 'Tap the playfield to place the moving block',
|
||||
gameOver: 'Tower collapsed',
|
||||
height: 'Height',
|
||||
mainMenu: 'Main Menu',
|
||||
menuBody:
|
||||
'Stop each moving block above the tower. Only the overlapping part remains.',
|
||||
menuTitle: 'How high can you build?',
|
||||
mute: 'Mute game sounds',
|
||||
newGame: 'Start New Game',
|
||||
pause: 'Pause or resume game',
|
||||
paused: 'Paused',
|
||||
perfect: 'PERFECT!',
|
||||
placeBlock: 'Place moving block',
|
||||
playAgain: 'Play Again',
|
||||
ready: 'Ready to stack?',
|
||||
resume: 'Continue Game',
|
||||
score: 'Score',
|
||||
start: 'Start Game',
|
||||
tapHint: 'Every tap places one block · perfect hits earn bonus points',
|
||||
unmute: 'Turn on game sounds',
|
||||
},
|
||||
skyFlappy: {
|
||||
name: 'Sky Flappy',
|
||||
backToMenu: 'Back to game menu',
|
||||
best: 'Best',
|
||||
design: 'Sky design',
|
||||
designs: { dawn: 'Dawn', neon: 'Neon', storm: 'Storm' },
|
||||
eyebrow: 'Sky challenge',
|
||||
firstTap: 'Tap to launch',
|
||||
flap: 'Fly upward',
|
||||
gameHint: 'Tap anywhere in the sky to fly upward',
|
||||
gameOver: 'Flight ended',
|
||||
highScore: 'High Score',
|
||||
mainMenu: 'Main Menu',
|
||||
menuBody:
|
||||
'Guide the sky bird through every tower opening without touching the edges.',
|
||||
menuTitle: 'Fly between the towers',
|
||||
mute: 'Mute game sounds',
|
||||
pause: 'Pause or resume game',
|
||||
paused: 'Paused',
|
||||
playAgain: 'Fly Again',
|
||||
points: 'Points',
|
||||
ready: 'Ready for takeoff?',
|
||||
resume: 'Continue Flight',
|
||||
score: 'Score',
|
||||
start: 'Start Flight',
|
||||
tapHint: 'Tap to flap · gravity pulls the bird down',
|
||||
unmute: 'Turn on game sounds',
|
||||
},
|
||||
neonDrop: {
|
||||
name: 'Neon Drop',
|
||||
backToMenu: 'Back to game menu',
|
||||
bestLines: 'Best Lines',
|
||||
bestScore: 'Best Score',
|
||||
board: 'Neon Drop reactor board',
|
||||
controls: 'Block controls',
|
||||
eyebrow: 'Energy block puzzle',
|
||||
gameHint: 'Swipe the reactor or use the five controls',
|
||||
gameOver: 'Reactor overloaded',
|
||||
hardDrop: 'Drop block immediately',
|
||||
howToBody:
|
||||
'Fill complete horizontal rows to dissolve them and keep the reactor clear.',
|
||||
howToTitle: 'Charge the reactor',
|
||||
left: 'Move block left',
|
||||
level: 'Level',
|
||||
lines: 'Lines',
|
||||
mainMenu: 'Main Menu',
|
||||
menuBody:
|
||||
'Rotate and place falling energy shapes. Every cleared row increases your charge.',
|
||||
menuTitle: 'Build complete energy lines',
|
||||
mute: 'Mute game sounds',
|
||||
newGame: 'Start New Game',
|
||||
next: 'Next',
|
||||
pause: 'Pause or resume game',
|
||||
paused: 'Paused',
|
||||
playAgain: 'Play Again',
|
||||
points: 'Points',
|
||||
ready: 'Reactor ready',
|
||||
resume: 'Continue Game',
|
||||
right: 'Move block right',
|
||||
rotate: 'Rotate block',
|
||||
score: 'Score',
|
||||
softDrop: 'Move block down',
|
||||
start: 'Start Reactor',
|
||||
unmute: 'Turn on game sounds',
|
||||
},
|
||||
citymarkt: {
|
||||
name: 'CityMarkt',
|
||||
eyebrow: 'Los Santos marketplace',
|
||||
tabs: { discover: 'Discover', search: 'Search', sell: 'Sell', inbox: 'Inbox', profile: 'Me' },
|
||||
categories: {
|
||||
vehicles: 'Vehicles', property: 'Property', electronics: 'Electronics', clothing: 'Clothing',
|
||||
tools: 'Tools', leisure: 'Leisure', services: 'Services', jobs: 'Jobs', wanted: 'Wanted', other: 'Other',
|
||||
},
|
||||
conditions: { new: 'New', very_good: 'Very good', used: 'Used', defective: 'Defective' },
|
||||
priceTypes: { fixed: 'Fixed price', negotiable: 'Negotiable', free: 'Free' },
|
||||
offerStatus: {
|
||||
pending: 'Open',
|
||||
accepted: 'Accepted',
|
||||
declined: 'Declined',
|
||||
countered: 'Negotiated',
|
||||
},
|
||||
districts: {
|
||||
los_santos: 'Los Santos', vinewood: 'Vinewood', vespucci: 'Vespucci',
|
||||
south_los_santos: 'South Los Santos', sandy_shores: 'Sandy Shores',
|
||||
paleto_bay: 'Paleto Bay', blaine_county: 'Blaine County',
|
||||
},
|
||||
status: { active: 'Active', reserved: 'Reserved', sold: 'Sold', expired: 'Expired', removed: 'Removed' },
|
||||
reportReasons: { prohibited: 'Prohibited item', fraud: 'Suspected fraud', spam: 'Spam', offensive: 'Offensive content', other: 'Other' },
|
||||
searchPlaceholder: 'What are you looking for?', allCategories: 'All categories', allDistricts: 'All districts',
|
||||
sortNewest: 'Newest first', sortPriceAsc: 'Lowest price', sortPriceDesc: 'Highest price',
|
||||
freshOffers: 'Fresh offers', offers: 'offers', noListings: 'No offers found',
|
||||
noListingsBody: 'Try another search or category.', noDistrict: 'No district',
|
||||
free: 'Free', negotiablePrice: '${price} negotiable', money: '${price}',
|
||||
hoursAgo: '{count}h ago', daysAgo: '{count}d ago', photos: 'photos', activeListings: 'active listings',
|
||||
signInTitle: 'Sign in to iFruit', signInBody: 'Use Settings to sign in before selling, saving or messaging.',
|
||||
noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.',
|
||||
myListings: 'My listings', favorites: 'Favorites', noProfileListings: 'Nothing here yet',
|
||||
phone: 'Phone', contactSeller: 'Contact seller', messagePlaceholder: 'Hi, is this still available?',
|
||||
signInToMessage: 'Sign in to your iFruit account to send a message.',
|
||||
edit: 'Edit', makeActive: 'Make active', markSold: 'Mark sold', remove: 'Remove',
|
||||
createListing: 'Create listing', editListing: 'Edit listing', save: 'Save', step: 'Step {current} of {total}', next: 'Next', previous: 'Back', publish: 'Publish',
|
||||
addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.',
|
||||
chooseGallery: 'Choose from gallery', chooseGalleryBody: 'Use photos saved on this phone.',
|
||||
takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.',
|
||||
selectedPhotos: 'Selected photos', gallery: 'Gallery', camera: 'Camera', takePhoto: 'Take photo',
|
||||
cameraHint: 'Take as many photos as you need. Each shot is added to the listing automatically.',
|
||||
noPhoto: 'No photo available', noPhotoBody: 'The seller did not add a photo to this listing.',
|
||||
noPhotoOptional: 'You can publish without a photo or add one from the gallery or camera.',
|
||||
previousPhoto: 'Previous photo', nextPhoto: 'Next photo', photo: 'Photo',
|
||||
removePhoto: 'Remove photo {number}', photoLimit: 'You can add up to six photos.',
|
||||
describeOffer: 'Describe your offer', title: 'Title', description: 'Description', category: 'Category', condition: 'Condition',
|
||||
characterCount: '{current} / {maximum} characters', minimumCharacters: 'min. {minimum}',
|
||||
priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District',
|
||||
showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.',
|
||||
writeMessage: 'Write a message', reserveForBuyer: 'Reserve for this buyer', statusChanged: 'Listing updated.',
|
||||
offer: 'Offer', counterOffer: 'Counteroffer', makeOffer: 'Make an offer',
|
||||
offeredByYou: 'You sent this offer.', offeredToYou: 'You received this offer.',
|
||||
acceptOffer: 'Accept', declineOffer: 'Decline', negotiateOffer: 'Counter',
|
||||
offerFor: 'Your offer for', offerAmount: 'Offer amount', sendOffer: 'Send offer',
|
||||
offerSent: 'Your offer was sent.', offerAccepted: 'Offer accepted.', offerDeclined: 'Offer declined.',
|
||||
reportListing: 'Report listing', reportWhy: 'Why are you reporting this?', reportDetails: 'Add details (optional)',
|
||||
sendReport: 'Send report', blockSeller: 'Block seller', reported: 'Report sent.', blocked: 'Seller blocked.',
|
||||
newMessage: 'New CityMarkt message from {sender}',
|
||||
newOffer: '{sender} offered ${price}.',
|
||||
offerAcceptedNotification: '{sender} accepted your ${price} offer.',
|
||||
offerRejectedNotification: '{sender} declined your ${price} offer.',
|
||||
errors: {
|
||||
invalid_listing: 'Check the listing details.', invalid_price: 'Enter a valid price.',
|
||||
invalid_images: 'Choose valid photos from this phone.', phone_unavailable: 'Insert a SIM or hide your number.',
|
||||
listing_limit: 'You have reached the active listing limit.', listing_not_found: 'This listing is no longer available.',
|
||||
invalid_message: 'Enter a valid message.', inquiry_not_found: 'This conversation is no longer available.',
|
||||
invalid_offer: 'Enter a valid whole-number offer.', invalid_offer_response: 'This offer action is invalid.',
|
||||
offer_listing_unavailable: 'This listing is no longer available for offers.',
|
||||
offer_closed: 'This negotiation is already complete.', offer_waiting: 'Wait for the other person to respond.',
|
||||
offer_not_allowed: 'You cannot make an offer right now.', offer_not_actionable: 'This offer can no longer be changed.',
|
||||
offer_conflict: 'The offer changed. Please try again.',
|
||||
blocked: 'Messages are blocked between these accounts.', already_reported: 'You already reported this listing.',
|
||||
rate_limited: 'Too many requests. Try again shortly.', not_authenticated: 'Sign in to your iFruit account first.',
|
||||
conflict: 'The listing changed. Open it again.', request_failed: 'CityMarkt is temporarily unavailable.',
|
||||
default: 'The CityMarkt request failed.',
|
||||
},
|
||||
},
|
||||
localPages: {
|
||||
name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos',
|
||||
heroTitle: 'What is happening nearby?', heroBody: 'Discover places, people and local tips.',
|
||||
searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories',
|
||||
allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago',
|
||||
categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' },
|
||||
discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts',
|
||||
noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached',
|
||||
signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
|
||||
localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes',
|
||||
location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing',
|
||||
newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.',
|
||||
title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...',
|
||||
photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.',
|
||||
cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day',
|
||||
errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' },
|
||||
},
|
||||
map: {
|
||||
name: 'Map',
|
||||
controls: 'Map controls',
|
||||
@@ -267,27 +572,93 @@ const defaultLocales: LocaleTree = {
|
||||
},
|
||||
camera: {
|
||||
name: 'Camera',
|
||||
shutter: 'Take photo',
|
||||
flash: 'Flash',
|
||||
flip: 'Flip camera',
|
||||
flash: 'Toggle flash',
|
||||
controls: 'Camera controls',
|
||||
landscape: 'Switch to landscape',
|
||||
portrait: 'Switch to portrait',
|
||||
photo: 'Photo',
|
||||
video: 'Video',
|
||||
focusHelp: 'Space for movement',
|
||||
returnHelp: 'Space to return',
|
||||
uploading: '{count} uploading',
|
||||
saving: 'Saving video...',
|
||||
openGallery: 'Open Gallery',
|
||||
takePhoto: 'Take photo',
|
||||
startRecording: 'Start recording',
|
||||
stopRecording: 'Stop recording',
|
||||
saved: 'Saved to Gallery.',
|
||||
zoom: 'Set camera zoom to {zoom}',
|
||||
errors: {
|
||||
media_provider_unconfigured: 'Photo uploads are not configured.',
|
||||
capture_provider_unavailable: 'The screenshot resource is unavailable.',
|
||||
capture_failed: 'The photo could not be captured.',
|
||||
video_provider_unavailable: 'Video capture requires the screencapture resource.',
|
||||
video_capture_failed: 'The video could not be recorded.',
|
||||
recording_in_progress: 'A video is already being recorded.',
|
||||
recording_not_found: 'No active video recording was found.',
|
||||
cancelled: 'Capture cancelled.',
|
||||
capture_failed: 'Unable to capture the game view.',
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
operation_in_progress:
|
||||
'Another media operation is already in progress.',
|
||||
owner_changed: 'The active phone account changed during upload.',
|
||||
rate_limited: 'Too many media actions. Try again shortly.',
|
||||
request_failed: 'The camera request failed.',
|
||||
request_timeout: 'The media service timed out.',
|
||||
unsupported: 'Video recording is not supported.',
|
||||
upload_failed: 'The media upload failed.',
|
||||
upload_timeout: 'The media upload timed out.',
|
||||
},
|
||||
modes: {
|
||||
timelapse: 'Timelapse',
|
||||
slowMo: 'Slow-Mo',
|
||||
cinematic: 'Cinematic',
|
||||
video: 'Video',
|
||||
photo: 'Photo',
|
||||
portrait: 'Portrait',
|
||||
pano: 'Pano',
|
||||
},
|
||||
calendar: {
|
||||
name: 'Calendar',
|
||||
today: 'Today',
|
||||
calendars: 'Calendars',
|
||||
calendar: 'Calendar',
|
||||
calendarName: 'iFruit',
|
||||
searchPlaceholder: 'Search events',
|
||||
previousMonth: 'Previous month',
|
||||
nextMonth: 'Next month',
|
||||
eyebrow: 'Your time. Clearly planned.',
|
||||
schedule: 'Schedule',
|
||||
event: 'Event',
|
||||
appointment: 'Appointment',
|
||||
newEvent: 'New event',
|
||||
editEvent: 'Edit event',
|
||||
deleteEvent: 'Delete event',
|
||||
details: 'Event details',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'What is planned?',
|
||||
date: 'Date',
|
||||
starts: 'Starts',
|
||||
ends: 'Ends',
|
||||
reminder: 'Reminder',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Add details, an address or anything to remember...',
|
||||
noEvents: 'Nothing planned',
|
||||
noEventsBody: 'This day is still free. Add an event whenever you are ready.',
|
||||
signInTitle: 'Your iFruit calendar',
|
||||
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
|
||||
reminderNotification: 'Upcoming: {title}',
|
||||
views: {
|
||||
compact: 'Compact',
|
||||
stacked: 'Stacked',
|
||||
details: 'Details',
|
||||
list: 'List',
|
||||
},
|
||||
reminders: {
|
||||
none: 'No reminder',
|
||||
atStart: 'At start time',
|
||||
tenMinutes: '10 minutes before',
|
||||
thirtyMinutes: '30 minutes before',
|
||||
oneHour: '1 hour before',
|
||||
oneDay: '1 day before',
|
||||
},
|
||||
errors: {
|
||||
invalid_event: 'Enter a title and make sure the end is after the start.',
|
||||
invalid_range: 'This calendar period is invalid.',
|
||||
conflict: 'This event changed on another phone. Open it again.',
|
||||
rate_limited: 'Too many changes. Try again shortly.',
|
||||
not_authenticated: 'Sign in to your iFruit account first.',
|
||||
request_failed: 'Calendar is temporarily unavailable.',
|
||||
default: 'The calendar request failed.',
|
||||
},
|
||||
},
|
||||
clock: {
|
||||
@@ -373,6 +744,9 @@ const defaultLocales: LocaleTree = {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
accountEyebrow: 'Your iFruit account',
|
||||
emailPlaceholder: 'name@ifruit.com',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
passwordWarning:
|
||||
'Use an in-character password. Do not reuse a real-world password.',
|
||||
mailboxes: 'Mailboxes',
|
||||
@@ -383,9 +757,14 @@ const defaultLocales: LocaleTree = {
|
||||
compose: 'New Message',
|
||||
recipients: 'To',
|
||||
recipientHint: 'Separate up to 10 addresses with commas.',
|
||||
recipientPlaceholder: 'name@ifruit.com',
|
||||
subject: 'Subject',
|
||||
subjectPlaceholder: "What's this about?",
|
||||
body: 'Message',
|
||||
messagePlaceholder: 'Write a message...',
|
||||
search: 'Search mail',
|
||||
messages: 'messages',
|
||||
noMessageContent: 'No message content',
|
||||
noMail: 'No Mail',
|
||||
noMailBody: 'Messages in this mailbox will appear here.',
|
||||
noResults: 'No Results',
|
||||
@@ -398,7 +777,16 @@ const defaultLocales: LocaleTree = {
|
||||
reply: 'Reply',
|
||||
replyAll: 'Reply All',
|
||||
forward: 'Forward',
|
||||
formatBold: 'Bold',
|
||||
formatItalic: 'Italic',
|
||||
formatBulletList: 'Bullet list',
|
||||
formatNumberedList: 'Numbered list',
|
||||
formatQuote: 'Quote',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
markRead: 'Read',
|
||||
markUnread: 'Mark as Unread',
|
||||
delete: 'Delete',
|
||||
moveToTrash: 'Move to Trash',
|
||||
restore: 'Restore',
|
||||
deleteForever: 'Delete Forever',
|
||||
@@ -447,38 +835,40 @@ const defaultLocales: LocaleTree = {
|
||||
deleteNote: 'Delete note',
|
||||
},
|
||||
photos: {
|
||||
name: 'Photos',
|
||||
searchPlaceholder: 'Photos, people, places...',
|
||||
recents: 'Recents',
|
||||
favorites: 'Favorites',
|
||||
items: 'items',
|
||||
memories: 'Memories',
|
||||
featured: 'City colors',
|
||||
dateRange: '19 Apr–7 May 2024',
|
||||
place: 'Los Santos & more',
|
||||
select: 'Select',
|
||||
count: '3,042 Photos, 125 Videos',
|
||||
years: 'Years',
|
||||
months: 'Months',
|
||||
days: 'Days',
|
||||
allPhotos: 'All Photos',
|
||||
seeAll: 'See All',
|
||||
onThisDay: 'On This Day',
|
||||
trip: 'MAR 2024 TRIP',
|
||||
featuredPhotos: 'Featured Photos',
|
||||
featuredDate: '30 Mar 2024',
|
||||
tabs: {
|
||||
library: 'Library',
|
||||
forYou: 'For You',
|
||||
albums: 'Albums',
|
||||
search: 'Search',
|
||||
},
|
||||
samples: {
|
||||
sunset: 'Sunset drive',
|
||||
ocean: 'Ocean air',
|
||||
city: 'City lights',
|
||||
desert: 'Desert road',
|
||||
capture: 'Camera capture',
|
||||
name: 'Gallery',
|
||||
count: '{count} items',
|
||||
loading: 'Loading Gallery...',
|
||||
emptyTitle: 'No Photos or Videos',
|
||||
emptyBody: 'Captures from Camera will appear here.',
|
||||
photo: 'Photo',
|
||||
video: 'Video',
|
||||
photoAlt: 'Gallery photo',
|
||||
videoAlt: 'Gallery video',
|
||||
delete: 'Delete media',
|
||||
deleteTitle: 'Delete Media?',
|
||||
deleteBody: 'This photo or video will be permanently deleted.',
|
||||
deleted: 'Media deleted.',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out',
|
||||
resetZoom: 'Reset zoom',
|
||||
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
|
||||
errors: {
|
||||
cancelled: 'The media action was cancelled.',
|
||||
capture_failed: 'Unable to capture the game view.',
|
||||
invalid_media_type: 'The media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
missing_config: 'Gallery uploads are not configured.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
operation_in_progress:
|
||||
'Another media operation is already in progress.',
|
||||
owner_changed: 'The active phone account changed.',
|
||||
rate_limited: 'Too many media actions. Try again shortly.',
|
||||
request_failed: 'The Gallery request failed.',
|
||||
request_timeout: 'The media service timed out.',
|
||||
unsupported: 'This media format is not supported.',
|
||||
upload_failed: 'The media upload failed.',
|
||||
upload_timeout: 'The media upload timed out.',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
@@ -594,6 +984,7 @@ const defaultLocales: LocaleTree = {
|
||||
Common: {
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
close: 'Close',
|
||||
back: 'Back',
|
||||
delete: 'Delete',
|
||||
@@ -657,6 +1048,7 @@ function getByPath(source: LocaleTree, path: string): unknown {
|
||||
|
||||
export const usePhoneStore = defineStore('phone', {
|
||||
state: () => ({
|
||||
cameraLandscape: false,
|
||||
currentPage: 1,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
@@ -664,7 +1056,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
lang: 'en',
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: structuredClone(DEFAULT_PHONE_PREFERENCES),
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}),
|
||||
getters: {
|
||||
@@ -676,6 +1068,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
},
|
||||
actions: {
|
||||
close(): void {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
@@ -717,11 +1110,14 @@ export const usePhoneStore = defineStore('phone', {
|
||||
setCurrentPage(page: number): void {
|
||||
this.currentPage = clampPage(page)
|
||||
},
|
||||
setCameraLandscape(landscape: boolean): void {
|
||||
this.cameraLandscape = landscape
|
||||
},
|
||||
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
|
||||
this.launchOrigin = origin
|
||||
},
|
||||
setAppNotification(
|
||||
appId: PhoneAppId,
|
||||
appId: LaunchablePhoneAppId,
|
||||
key: keyof AppNotificationPreferences,
|
||||
value: boolean,
|
||||
): void {
|
||||
|
||||
@@ -4,7 +4,9 @@ export type PhoneAppId =
|
||||
| 'phone'
|
||||
| 'messages'
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'mail'
|
||||
| 'map'
|
||||
@@ -12,6 +14,17 @@ export type PhoneAppId =
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
| 'settings'
|
||||
| 'snake'
|
||||
| 'memory'
|
||||
| 'number-merge'
|
||||
| 'minesweeper'
|
||||
| 'tower-stack'
|
||||
| 'sky-flappy'
|
||||
| 'neon-drop'
|
||||
| 'citymarkt'
|
||||
| 'local-pages'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
@@ -22,7 +35,7 @@ export type AppLaunchOrigin = {
|
||||
}
|
||||
|
||||
export type PhoneAppDefinition = {
|
||||
component: Component
|
||||
component: Component | null
|
||||
dockOrder: number | null
|
||||
gridOrder: number
|
||||
icon: Component
|
||||
@@ -30,5 +43,11 @@ export type PhoneAppDefinition = {
|
||||
iconImage: string
|
||||
id: PhoneAppId
|
||||
labelKey: string
|
||||
route: `/apps/${PhoneAppId}`
|
||||
route: `/apps/${LaunchablePhoneAppId}` | null
|
||||
}
|
||||
|
||||
export type LaunchablePhoneAppDefinition = PhoneAppDefinition & {
|
||||
component: Component
|
||||
id: LaunchablePhoneAppId
|
||||
route: `/apps/${LaunchablePhoneAppId}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export type CalendarEvent = {
|
||||
endsAt: number
|
||||
id: string
|
||||
note: string
|
||||
remindedAt: number | null
|
||||
reminderMinutes: number | null
|
||||
revision: number
|
||||
startsAt: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export type CalendarEventDraft = {
|
||||
endsAt: number
|
||||
note: string
|
||||
reminderMinutes: number | null
|
||||
startsAt: number
|
||||
title: string
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type MarketplaceCategory =
|
||||
| 'vehicles'
|
||||
| 'property'
|
||||
| 'electronics'
|
||||
| 'clothing'
|
||||
| 'tools'
|
||||
| 'leisure'
|
||||
| 'services'
|
||||
| 'jobs'
|
||||
| 'wanted'
|
||||
| 'other'
|
||||
|
||||
export type MarketplaceCondition = 'new' | 'very_good' | 'used' | 'defective'
|
||||
export type MarketplacePriceType = 'fixed' | 'negotiable' | 'free'
|
||||
export type MarketplaceStatus = 'active' | 'reserved' | 'sold' | 'expired' | 'removed'
|
||||
export type MarketplaceOfferStatus = 'pending' | 'accepted' | 'rejected' | 'countered'
|
||||
|
||||
export type MarketplaceImage = {
|
||||
gradient: string
|
||||
media_id: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export type MarketplaceListingSummary = {
|
||||
category: MarketplaceCategory
|
||||
created_at: string
|
||||
district: string | null
|
||||
expires_at: string
|
||||
id: string
|
||||
image: string | null
|
||||
is_favorite: boolean | number
|
||||
item_condition: MarketplaceCondition
|
||||
price: number | string | null
|
||||
price_type: MarketplacePriceType
|
||||
seller_name: string
|
||||
status: MarketplaceStatus
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MarketplaceListing = MarketplaceListingSummary & {
|
||||
description: string
|
||||
images: MarketplaceImage[]
|
||||
is_owner: boolean
|
||||
phone_number: string | null
|
||||
reserved_account_id: number | null
|
||||
revision: number
|
||||
seller_active: number
|
||||
seller_since: string
|
||||
show_phone: boolean | number
|
||||
}
|
||||
|
||||
export type MarketplaceListingDraft = {
|
||||
category: MarketplaceCategory
|
||||
condition: MarketplaceCondition
|
||||
description: string
|
||||
district: string
|
||||
images: Array<{ id: string }>
|
||||
price: number | null
|
||||
priceType: MarketplacePriceType
|
||||
showPhone: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
export type MarketplaceInquirySummary = {
|
||||
buyer_account_id: number
|
||||
id: string
|
||||
image: string | null
|
||||
last_message: string | null
|
||||
listing_id: string
|
||||
other_name: string
|
||||
price: number | string | null
|
||||
price_type: MarketplacePriceType
|
||||
seller_account_id: number
|
||||
status: MarketplaceStatus
|
||||
title: string
|
||||
unread: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MarketplaceMessage = {
|
||||
body: string
|
||||
created_at: string
|
||||
id: number
|
||||
read_at: string | null
|
||||
sender_account_id: number
|
||||
}
|
||||
|
||||
export type MarketplaceOffer = {
|
||||
amount: number | string
|
||||
created_at: string
|
||||
id: number
|
||||
proposer_account_id: number
|
||||
read_at: string | null
|
||||
response_read_at: string | null
|
||||
status: MarketplaceOfferStatus
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MarketplaceInquiry = {
|
||||
buyer_account_id: number
|
||||
buyer_name: string
|
||||
id: string
|
||||
listing_id: string
|
||||
offer_amount: number | string | null
|
||||
offer_id: number | null
|
||||
offer_proposer_account_id: number | null
|
||||
offer_revision: number
|
||||
offer_status: Exclude<MarketplaceOfferStatus, 'countered'> | null
|
||||
price: number | string | null
|
||||
price_type: MarketplacePriceType
|
||||
reserved_account_id: number | null
|
||||
seller_account_id: number
|
||||
seller_name: string
|
||||
status: MarketplaceStatus
|
||||
title: string
|
||||
}
|
||||
|
||||
export type MarketplaceChat = {
|
||||
accountId: number
|
||||
inquiry: MarketplaceInquiry
|
||||
messages: MarketplaceMessage[]
|
||||
offers: MarketplaceOffer[]
|
||||
}
|
||||
|
||||
export type MarketplaceCounts = { active: number; unread: number }
|
||||
@@ -0,0 +1,39 @@
|
||||
export type MediaType = 'photo' | 'video'
|
||||
export type GalleryFilter = 'all' | MediaType
|
||||
|
||||
export type PhoneMedia = {
|
||||
createdAt: number
|
||||
id: number
|
||||
mediaType: MediaType
|
||||
url: string
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
Encoding?: 'jpg' | 'png' | 'webp'
|
||||
Quality?: number
|
||||
}
|
||||
presignedUrl: string
|
||||
requestId: string
|
||||
uploadTimeoutMs?: number
|
||||
video?: {
|
||||
BitrateKbps?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type UploadResult = {
|
||||
correlationId: string
|
||||
error?: string
|
||||
media?: PhoneMedia
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type DeleteResult = {
|
||||
correlationId: string
|
||||
error?: string
|
||||
id?: number
|
||||
success: boolean
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type PagesCategory =
|
||||
| 'recommendation'
|
||||
| 'wanted'
|
||||
| 'service'
|
||||
| 'event'
|
||||
| 'place'
|
||||
| 'community'
|
||||
| 'citymarkt'
|
||||
|
||||
export type PagesImage = {
|
||||
gradient: string
|
||||
media_id: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export type PagesPost = {
|
||||
author_name: string
|
||||
body: string
|
||||
category: PagesCategory
|
||||
citymarkt_listing_id: string | null
|
||||
citymarkt_price: number | string | null
|
||||
created_at: number
|
||||
district: string | null
|
||||
id: string
|
||||
image: string | null
|
||||
images: PagesImage[]
|
||||
is_liked: boolean | number
|
||||
is_owner: boolean | number
|
||||
is_saved: boolean | number
|
||||
like_count: number
|
||||
source_type: 'personal' | 'citymarkt'
|
||||
title: string
|
||||
}
|
||||
|
||||
export type PagesPostDraft = {
|
||||
body: string
|
||||
category: Exclude<PagesCategory, 'citymarkt'>
|
||||
district: string
|
||||
images: Array<{ id: string }>
|
||||
title: string
|
||||
}
|
||||
|
||||
export type PagesPage = {
|
||||
hasMore: boolean
|
||||
items: PagesPost[]
|
||||
offset: number
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
|
||||
export const ALARM_SOUND_IDS = [
|
||||
'radar',
|
||||
'beacon',
|
||||
@@ -89,7 +91,7 @@ function readAlarm(value: unknown): Alarm | null {
|
||||
}
|
||||
|
||||
export function parseAlarms(value: unknown): Alarm[] {
|
||||
if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS)
|
||||
if (!Array.isArray(value)) return cloneJsonData(DEFAULT_ALARMS)
|
||||
return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function cloneJsonData<T>(value: T): T {
|
||||
const serialized = JSON.stringify(value)
|
||||
if (serialized === undefined) return value
|
||||
return JSON.parse(serialized) as T
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gameViewGeometry } from '@/utils/gameView'
|
||||
|
||||
describe('gameViewGeometry', () => {
|
||||
it('center-crops a widescreen game view for 3:4 portrait output', () => {
|
||||
const geometry = gameViewGeometry(1920, 1080, 540, 720)
|
||||
expect(Array.from(geometry.textureCoordinates)).toEqual([
|
||||
expect.closeTo(0.29, 2),
|
||||
0,
|
||||
expect.closeTo(0.71, 2),
|
||||
0,
|
||||
expect.closeTo(0.29, 2),
|
||||
1,
|
||||
expect.closeTo(0.71, 2),
|
||||
1,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the full game view for 16:9 landscape output', () => {
|
||||
const geometry = gameViewGeometry(1920, 1080, 720, 405)
|
||||
expect(Array.from(geometry.textureCoordinates)).toEqual([
|
||||
0, 0, 1, 0, 0, 1, 1, 1,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the full texture when both aspect ratios match', () => {
|
||||
const geometry = gameViewGeometry(1600, 900, 800, 450)
|
||||
expect(Array.from(geometry.textureCoordinates)).toEqual([
|
||||
0, 0, 1, 0, 0, 1, 1, 1,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps 0.5x full-frame while higher zoom levels crop around the center', () => {
|
||||
const wideGeometry = gameViewGeometry(1920, 1080, 540, 720, 0.5)
|
||||
expect(Array.from(wideGeometry.textureCoordinates)).toEqual([
|
||||
expect.closeTo(0.29, 2),
|
||||
0,
|
||||
expect.closeTo(0.71, 2),
|
||||
0,
|
||||
expect.closeTo(0.29, 2),
|
||||
1,
|
||||
expect.closeTo(0.71, 2),
|
||||
1,
|
||||
])
|
||||
expect(Array.from(wideGeometry.positions)).toEqual([
|
||||
-1, -1, 1, -1, -1, 1, 1, 1,
|
||||
])
|
||||
|
||||
const zoomedGeometry = gameViewGeometry(1920, 1080, 540, 720, 2)
|
||||
expect(Array.from(zoomedGeometry.textureCoordinates)).toEqual([
|
||||
expect.closeTo(0.39, 2),
|
||||
0.25,
|
||||
expect.closeTo(0.61, 2),
|
||||
0.25,
|
||||
expect.closeTo(0.39, 2),
|
||||
0.75,
|
||||
expect.closeTo(0.61, 2),
|
||||
0.75,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_texcoord;
|
||||
varying vec2 v_texcoord;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
v_texcoord = a_texcoord;
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
varying highp vec2 v_texcoord;
|
||||
uniform sampler2D u_texture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(u_texture, v_texcoord);
|
||||
}
|
||||
`
|
||||
|
||||
export interface GameView {
|
||||
readonly canvas: HTMLCanvasElement
|
||||
dispose(): void
|
||||
isLost(): boolean
|
||||
render(): void
|
||||
resize(
|
||||
width: number,
|
||||
height: number,
|
||||
sourceWidth?: number,
|
||||
sourceHeight?: number,
|
||||
zoom?: number,
|
||||
): void
|
||||
}
|
||||
|
||||
export interface GameViewOptions {
|
||||
preserveDrawingBuffer?: boolean
|
||||
}
|
||||
|
||||
export function gameViewGeometry(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
zoom = 1,
|
||||
): { positions: Float32Array; textureCoordinates: Float32Array } {
|
||||
const sourceAspect = sourceWidth / sourceHeight
|
||||
const targetAspect = targetWidth / targetHeight
|
||||
let left = 0
|
||||
let right = 1
|
||||
let top = 0
|
||||
let bottom = 1
|
||||
|
||||
if (sourceAspect > targetAspect) {
|
||||
const visibleWidth = targetAspect / sourceAspect
|
||||
left = (1 - visibleWidth) / 2
|
||||
right = 1 - left
|
||||
} else if (sourceAspect < targetAspect) {
|
||||
const visibleHeight = sourceAspect / targetAspect
|
||||
top = (1 - visibleHeight) / 2
|
||||
bottom = 1 - top
|
||||
}
|
||||
|
||||
const normalizedZoom = Math.min(3, Math.max(1, zoom))
|
||||
const centerX = (left + right) / 2
|
||||
const centerY = (top + bottom) / 2
|
||||
left = Math.max(0, centerX + (left - centerX) / normalizedZoom)
|
||||
right = Math.min(1, centerX + (right - centerX) / normalizedZoom)
|
||||
top = Math.max(0, centerY + (top - centerY) / normalizedZoom)
|
||||
bottom = Math.min(1, centerY + (bottom - centerY) / normalizedZoom)
|
||||
|
||||
return {
|
||||
positions: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
textureCoordinates: new Float32Array([
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
bottom,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
function compileShader(
|
||||
gl: WebGLRenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) throw new Error('game_view_shader_unavailable')
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
throw new Error(gl.getShaderInfoLog(shader) || 'game_view_shader_failed')
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
export function createGameView(
|
||||
canvas: HTMLCanvasElement,
|
||||
options: GameViewOptions = {},
|
||||
): GameView {
|
||||
const gl = canvas.getContext('webgl', {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
desynchronized: true,
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
|
||||
stencil: false,
|
||||
}) as WebGLRenderingContext | null
|
||||
if (!gl) throw new Error('game_view_unavailable')
|
||||
|
||||
let lost = false
|
||||
let disposed = false
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault()
|
||||
lost = true
|
||||
console.error('[Camera] Game-view WebGL context lost.')
|
||||
}
|
||||
canvas.addEventListener(
|
||||
'webglcontextlost',
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
|
||||
const program = gl.createProgram()
|
||||
if (!program) throw new Error('game_view_program_unavailable')
|
||||
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
|
||||
gl.attachShader(
|
||||
program,
|
||||
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
|
||||
)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
|
||||
}
|
||||
gl.useProgram(program)
|
||||
|
||||
const positionLocation = gl.getAttribLocation(program, 'a_position')
|
||||
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
|
||||
if (positionLocation < 0 || texcoordLocation < 0) {
|
||||
throw new Error('game_view_attributes_unavailable')
|
||||
}
|
||||
|
||||
const positionBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(positionLocation)
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const texcoordBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(texcoordLocation)
|
||||
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const texture = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([0, 0, 0, 255]),
|
||||
)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
|
||||
// game backbuffer. These calls are intentionally not redundant.
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
|
||||
return {
|
||||
canvas,
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
canvas.removeEventListener(
|
||||
'webglcontextlost',
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
},
|
||||
isLost: () => lost,
|
||||
render() {
|
||||
if (disposed || lost) return
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
gl.finish()
|
||||
},
|
||||
resize(
|
||||
width: number,
|
||||
height: number,
|
||||
sourceWidth = window.innerWidth,
|
||||
sourceHeight = window.innerHeight,
|
||||
zoom = 1,
|
||||
) {
|
||||
if (disposed || lost) return
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const geometry = gameViewGeometry(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
geometry.textureCoordinates,
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.viewport(0, 0, width, height)
|
||||
},
|
||||
}
|
||||
}
|
||||