ENH - update SMS branch from dev

This commit is contained in:
Eichenholz
2026-08-06 19:42:54 +02:00
219 changed files with 29676 additions and 1309 deletions
+135 -19
View File
@@ -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"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+56 -495
View File
@@ -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;
+10 -3
View File
@@ -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>
+49 -41
View File
@@ -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>
+43 -3
View File
@@ -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>
+74 -8
View File
@@ -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'])
})
})
+182 -6
View File
@@ -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',
}
}
+113
View File
@@ -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)
})
})
+118
View File
@@ -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
}
+25
View File
@@ -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
}
+28
View File
@@ -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,
})
},
},
})
+61
View File
@@ -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',
})
})
})
+63
View File
@@ -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
},
},
})
+4 -3
View File
@@ -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()
+50
View File
@@ -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',
})
})
})
+129
View File
@@ -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 })
},
},
})
+39
View File
@@ -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: [],
})
})
})
+19
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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
+33
View File
@@ -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)
})
})
+63
View File
@@ -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
},
},
})
+449 -53
View File
@@ -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 Apr7 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 {
+21 -2
View File
@@ -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}`
}
+18
View File
@@ -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
}
+126
View File
@@ -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 }
+39
View File
@@ -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
}
+47
View File
@@ -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
}
+3 -1
View File
@@ -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)
}
+5
View File
@@ -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
}
+62
View File
@@ -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,
])
})
})
+238
View File
@@ -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)
},
}
}

Some files were not shown because too many files have changed in this diff Show More