feat: complete hosted mod sync and launcher interface updates
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
This commit is contained in:
@ -1,11 +1,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@ -15,6 +15,7 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
interface LoadingEventPayload {
|
||||
total?: number | null
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
@ -22,6 +23,7 @@ interface LoadingEventPayload {
|
||||
}
|
||||
|
||||
interface LaunchProgressItem {
|
||||
waiting: boolean
|
||||
key: string
|
||||
message: string
|
||||
fraction: number | null
|
||||
@ -29,6 +31,7 @@ interface LaunchProgressItem {
|
||||
|
||||
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
|
||||
const LAUNCH_BAR_TYPES = new Set([
|
||||
'hosted_pack_sync',
|
||||
'minecraft_download',
|
||||
'instance_update',
|
||||
'zip_extract',
|
||||
@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
|
||||
if (!isVisible(payload.event)) return
|
||||
|
||||
activeMap.set(payload.loader_uuid, {
|
||||
waiting: payload.total === 0,
|
||||
key: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
fraction: payload.fraction,
|
||||
@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
|
||||
const hasProgress = computed(() => progressItems.value.length > 0)
|
||||
|
||||
function percent(item: LaunchProgressItem): string {
|
||||
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
||||
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
||||
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
|
||||
}
|
||||
|
||||
let initializing = true
|
||||
const buffered: LoadingEventPayload[] = []
|
||||
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
|
||||
applyEvent(payload)
|
||||
if (initializing) buffered.push(payload)
|
||||
else applyEvent(payload)
|
||||
})
|
||||
const bars = await progress_bars_list().catch(() => ({}))
|
||||
for (const bar of Object.values(bars)) {
|
||||
applyEvent({
|
||||
event: bar.bar_type,
|
||||
loader_uuid: String(bar.loading_bar_uuid),
|
||||
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
|
||||
total: bar.total,
|
||||
message: bar.message ?? '',
|
||||
})
|
||||
}
|
||||
initializing = false
|
||||
for (const payload of buffered) applyEvent(payload)
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenLoading?.()
|
||||
@ -96,12 +115,7 @@ onUnmounted(() => {
|
||||
{{ percent(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
|
||||
<div
|
||||
class="h-full rounded-full bg-brand transition-[width] duration-200"
|
||||
:style="{ width: percent(item) || '100%' }"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="syncing" role="status">{{ formatMessage(messages.syncing) }}</p>
|
||||
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
|
||||
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
|
||||
<p>
|
||||
{{
|
||||
@ -59,10 +59,9 @@
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<p v-if="!loading && ready && !catalog.length">{{ formatMessage(messages.empty) }}</p>
|
||||
|
||||
<article
|
||||
v-for="pack in catalog"
|
||||
:key="pack.packId"
|
||||
v-if="pack"
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
|
||||
>
|
||||
<div>
|
||||
@ -73,16 +72,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand"
|
||||
><button
|
||||
type="button"
|
||||
:disabled="
|
||||
!ready ||
|
||||
syncing ||
|
||||
loading ||
|
||||
(!!binding && binding.publication.packId !== pack.packId)
|
||||
"
|
||||
@click="sync(pack.packId)"
|
||||
>
|
||||
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
|
||||
{{ formatMessage(binding ? messages.update : messages.install) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
@ -92,28 +82,29 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useHostedSync } from '@/composables/useHostedSync'
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
|
||||
import { useInstanceMode } from '@/composables/useInstanceMode'
|
||||
|
||||
import {
|
||||
type HostedBinding,
|
||||
hostedBinding,
|
||||
hostedCatalog,
|
||||
hostedDefault,
|
||||
type HostedPublication,
|
||||
hostedSync,
|
||||
type HostedSyncResult,
|
||||
} from '@/helpers/hosted-packs'
|
||||
const props = defineProps<{ instanceId: string }>()
|
||||
const modeQuery = useInstanceMode(() => props.instanceId)
|
||||
const router = useRouter()
|
||||
const showCreation = inject<
|
||||
(options: { skipSetupType: boolean; initialMode: 'import'; instanceMode: 'local' }) => void
|
||||
>('showCreationModalWithOptions')
|
||||
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
|
||||
'showCreationModalWithOptions',
|
||||
)
|
||||
function importLocal() {
|
||||
showCreation?.({ skipSetupType: true, initialMode: 'import', instanceMode: 'local' })
|
||||
showCreation?.({ skipSetupType: true, initialMode: 'import' })
|
||||
}
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
@ -125,11 +116,11 @@ const messages = defineMessages({
|
||||
},
|
||||
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
|
||||
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
|
||||
title: { id: 'app.hosted-packs.title', defaultMessage: 'Published modpacks' },
|
||||
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
|
||||
description: {
|
||||
id: 'app.hosted-packs.description',
|
||||
defaultMessage:
|
||||
'Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved.',
|
||||
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
|
||||
},
|
||||
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
|
||||
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
|
||||
@ -151,15 +142,27 @@ const messages = defineMessages({
|
||||
defaultMessage: 'No modpacks have been approved for publication yet.',
|
||||
},
|
||||
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
|
||||
install: { id: 'app.hosted-packs.install', defaultMessage: 'Install and enable automatic sync' },
|
||||
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
|
||||
})
|
||||
const catalog = ref<HostedPublication[]>([])
|
||||
const pack = ref<HostedPublication | null>(null)
|
||||
const binding = ref<HostedBinding | null>(null)
|
||||
const result = ref<HostedSyncResult | null>(null)
|
||||
const task = useHostedSync(() => props.instanceId)
|
||||
const result = task.result
|
||||
const manager = injectDownloadManager()
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const syncing = computed(
|
||||
() =>
|
||||
task.busy.value ||
|
||||
manager.legacyDownloads.value.some(
|
||||
(bar) =>
|
||||
bar.bar_type?.type === 'hosted_pack_sync' &&
|
||||
bar.bar_type.instance_id === props.instanceId &&
|
||||
!bar.bar_type.error,
|
||||
),
|
||||
)
|
||||
const ready = ref(false)
|
||||
const error = ref('')
|
||||
const loadError = ref('')
|
||||
const error = computed(() => loadError.value || task.error.value)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (modeQuery.data.value !== 'starlight') return
|
||||
@ -167,46 +170,36 @@ async function load() {
|
||||
const instanceId = props.instanceId
|
||||
loading.value = true
|
||||
ready.value = false
|
||||
error.value = ''
|
||||
loadError.value = ''
|
||||
try {
|
||||
const [packs, installed] = await Promise.all([hostedCatalog(), hostedBinding(instanceId)])
|
||||
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
|
||||
if (current !== generation) return
|
||||
catalog.value = packs
|
||||
pack.value = official
|
||||
binding.value = installed
|
||||
ready.value = true
|
||||
} catch (cause) {
|
||||
if (current === generation) error.value = String(cause)
|
||||
if (current === generation) loadError.value = String(cause)
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
async function sync(packId: string) {
|
||||
async function sync() {
|
||||
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
|
||||
const instanceId = props.instanceId
|
||||
syncing.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const completed = await hostedSync(instanceId, packId)
|
||||
if (props.instanceId !== instanceId) return
|
||||
result.value = completed
|
||||
await load()
|
||||
} catch (cause) {
|
||||
if (props.instanceId === instanceId) error.value = String(cause)
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
loadError.value = ''
|
||||
await task.sync()
|
||||
}
|
||||
watch(syncing, (busy, wasBusy) => {
|
||||
if (!busy && wasBusy) void load()
|
||||
})
|
||||
watch(
|
||||
() => [props.instanceId, modeQuery.data.value] as const,
|
||||
() => {
|
||||
generation++
|
||||
loading.value = false
|
||||
ready.value = false
|
||||
error.value = ''
|
||||
catalog.value = []
|
||||
loadError.value = ''
|
||||
pack.value = null
|
||||
binding.value = null
|
||||
result.value = null
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
const props = defineProps<{ instanceId?: string; active?: boolean }>()
|
||||
const manager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
preparing: {
|
||||
id: 'app.hosted-packs.progress.preparing',
|
||||
defaultMessage: 'Preparing modpack installation…',
|
||||
},
|
||||
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
|
||||
})
|
||||
const bar = computed(() =>
|
||||
manager.legacyDownloads.value.find(
|
||||
(item) =>
|
||||
props.instanceId &&
|
||||
item.bar_type?.type === 'hosted_pack_sync' &&
|
||||
!item.bar_type.error &&
|
||||
item.bar_type.instance_id === props.instanceId,
|
||||
),
|
||||
)
|
||||
const waiting = computed(() => !bar.value?.total)
|
||||
const current = computed(() =>
|
||||
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
|
||||
)
|
||||
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
|
||||
<ProgressBar
|
||||
:progress="current"
|
||||
:max="bar?.total || 1"
|
||||
:waiting="waiting"
|
||||
:label="message"
|
||||
label-class="min-w-0 break-all text-sm text-secondary"
|
||||
:show-progress="!waiting"
|
||||
full-width
|
||||
>
|
||||
<template #progress-icon />
|
||||
</ProgressBar>
|
||||
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@ -15,7 +15,7 @@ const messages = defineMessages({
|
||||
starlightDescription: {
|
||||
id: 'app.instance-mode.starlight-description',
|
||||
defaultMessage:
|
||||
'Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch.',
|
||||
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
|
||||
},
|
||||
localDescription: {
|
||||
id: 'app.instance-mode.local-description',
|
||||
|
||||
@ -11,7 +11,10 @@ const query = useInstanceMode(() => props.instanceId)
|
||||
const save = useSetInstanceMode()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
saving: { id: 'app.instance-mode.saving', defaultMessage: 'Saving instance type…' },
|
||||
saving: {
|
||||
id: 'app.instance-mode.saving',
|
||||
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
|
||||
},
|
||||
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
|
||||
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
|
||||
})
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
|
||||
import {
|
||||
createTaggedModProgress,
|
||||
type TaggedProgressEvent,
|
||||
type TaggedProgressGroup,
|
||||
} from '@/helpers/tagged-mod-progress'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const state = createTaggedModProgress()
|
||||
const groups = shallowRef<TaggedProgressGroup[]>([])
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
|
||||
description: {
|
||||
id: 'app.hosted-mods.description',
|
||||
defaultMessage: 'The game starts after all required updates have been installed.',
|
||||
},
|
||||
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
|
||||
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
|
||||
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
|
||||
})
|
||||
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
|
||||
let disposed = false
|
||||
let unlisten: (() => void) | undefined
|
||||
let stopAttempts: (() => void) | undefined
|
||||
function refresh() {
|
||||
groups.value = [...state.groups.values()]
|
||||
}
|
||||
function consume(payload: TaggedProgressEvent) {
|
||||
const opened = state.update(payload)
|
||||
refresh()
|
||||
if (opened)
|
||||
void nextTick(() => {
|
||||
const group = state.groups.get(payload.event?.batch_id ?? '')
|
||||
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
|
||||
})
|
||||
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
|
||||
modal.value?.hide()
|
||||
}
|
||||
onMounted(async () => {
|
||||
stopAttempts = onHostedPackAttemptStarted((id) => {
|
||||
state.reset(id)
|
||||
refresh()
|
||||
if (!groups.value.length) modal.value?.hide()
|
||||
})
|
||||
let initializing = true
|
||||
const buffered: TaggedProgressEvent[] = []
|
||||
const stop = await loading_listener((payload: TaggedProgressEvent) => {
|
||||
if (initializing) buffered.push(payload)
|
||||
else consume(payload)
|
||||
})
|
||||
if (disposed) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
unlisten = stop
|
||||
const bars = await progress_bars_list().catch(() => ({}))
|
||||
if (disposed) return
|
||||
const ordered = Object.values(bars).sort(
|
||||
(a, b) =>
|
||||
Number(b.bar_type?.type === 'hosted_pack_sync') -
|
||||
Number(a.bar_type?.type === 'hosted_pack_sync'),
|
||||
)
|
||||
for (const bar of ordered)
|
||||
consume({
|
||||
loader_uuid: String(bar.loading_bar_uuid),
|
||||
event: bar.bar_type,
|
||||
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
|
||||
total: bar.total,
|
||||
message: bar.message ?? '',
|
||||
})
|
||||
initializing = false
|
||||
for (const payload of buffered) consume(payload)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
unlisten?.()
|
||||
stopAttempts?.()
|
||||
})
|
||||
function openDownloads() {
|
||||
modal.value?.hide()
|
||||
void router.push('/downloads')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(38rem, calc(100vw - 2rem))"
|
||||
scrollable
|
||||
max-content-height="60vh"
|
||||
:close-on-click-outside="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
|
||||
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
|
||||
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
|
||||
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
|
||||
{{ group.message }}
|
||||
</p>
|
||||
<div
|
||||
v-for="file in group.files.values()"
|
||||
:key="file.id"
|
||||
class="flex min-w-0 flex-col gap-1"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-sm">
|
||||
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
|
||||
<span class="text-secondary tabular-nums"
|
||||
>{{ size(file.current) }} / {{ size(file.total) }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
|
||||
<ProgressBar
|
||||
v-else
|
||||
:progress="file.current"
|
||||
:max="file.total || 1"
|
||||
:waiting="!file.total"
|
||||
:show-progress="!file.done"
|
||||
:label="file.done ? formatMessage(messages.complete) : file.message"
|
||||
full-width
|
||||
><template #progress-icon
|
||||
/></ProgressBar>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled
|
||||
><button @click="openDownloads">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
|
||||
>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -312,6 +312,7 @@ interface RunningProcess {
|
||||
}
|
||||
|
||||
interface LoadingEventPayload {
|
||||
total?: number | null
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
@ -435,7 +436,6 @@ const unlistenProcess = await process_listener(async () => {
|
||||
const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@ -629,6 +629,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
|
||||
return (
|
||||
loadingBar.bar_type?.type !== 'launcher_update' &&
|
||||
[
|
||||
'hosted_pack_sync',
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
@ -655,8 +656,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
|
||||
const loadingBar = formatLoadingBars({
|
||||
loading_bar_uuid: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
current: payload.fraction,
|
||||
total: 1,
|
||||
current: payload.fraction * (payload.total ?? 1),
|
||||
total: payload.total ?? 1,
|
||||
bar_type: payload.event,
|
||||
})
|
||||
if (!isVisibleLoadingBar(loadingBar)) return false
|
||||
|
||||
@ -1,18 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { clearHostedSession } from '@/helpers/hosted-packs'
|
||||
|
||||
import {
|
||||
receiveSkinSiteMessage,
|
||||
resetSkinSiteSession,
|
||||
setSkinSiteFrame,
|
||||
SKIN_SITE_ORIGIN,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
|
||||
const frame = ref<HTMLIFrameElement>()
|
||||
let lastMessage = 0
|
||||
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
watch(
|
||||
[skinSiteStatus, () => skinSiteUser.value?.uuid],
|
||||
() => {
|
||||
void clearHostedSession().catch(() => {})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function connect() {
|
||||
resetSkinSiteSession()
|
||||
lastMessage = 0
|
||||
const contentWindow = frame.value?.contentWindow ?? null
|
||||
setSkinSiteFrame(contentWindow)
|
||||
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
|
||||
@ -35,6 +48,7 @@ onUnmounted(() => {
|
||||
clearInterval(expiryTimer)
|
||||
setSkinSiteFrame(null)
|
||||
resetSkinSiteSession()
|
||||
void clearHostedSession().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
274
apps/app-frontend/src/components/ui/about-scene/wither-shield.ts
Normal file
274
apps/app-frontend/src/components/ui/about-scene/wither-shield.ts
Normal file
@ -0,0 +1,274 @@
|
||||
type Point = [number, number]
|
||||
type Quad = [Point, Point, Point, Point]
|
||||
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
|
||||
|
||||
// Coordinates follow the sharp master with its localized shoulder cleanup,
|
||||
// normalized to the scene's 2048 × 682⅔ plate.
|
||||
// Each visible bone has its own face: the air between ribs must never receive armor.
|
||||
export const witherShieldSurfaces: ShieldSurface[] = [
|
||||
// Only the upper chest remains; the removed side beams have no shield surfaces.
|
||||
{
|
||||
plane: [
|
||||
[1194, 479],
|
||||
[1294, 489],
|
||||
[1283, 516],
|
||||
[1183, 505],
|
||||
],
|
||||
},
|
||||
// Far central skull: face, jaw wall and right side share the same edges.
|
||||
{
|
||||
plane: [
|
||||
[1162, 355],
|
||||
[1319, 352],
|
||||
[1267, 446],
|
||||
[1108, 442],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1108, 442],
|
||||
[1267, 446],
|
||||
[1280, 489],
|
||||
[1119, 470],
|
||||
],
|
||||
shade: 0.68,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1319, 352],
|
||||
[1356, 443],
|
||||
[1319, 492],
|
||||
[1267, 446],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
// Near-left skull, underneath the boot.
|
||||
{
|
||||
plane: [
|
||||
[794, 477],
|
||||
[968, 454],
|
||||
[1003, 549],
|
||||
[833, 602],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[794, 477],
|
||||
[833, 602],
|
||||
[821, 625],
|
||||
[781, 539],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[833, 602],
|
||||
[1003, 549],
|
||||
[983, 587],
|
||||
[821, 625],
|
||||
],
|
||||
shade: 0.65,
|
||||
},
|
||||
// Right skull.
|
||||
{
|
||||
plane: [
|
||||
[1482, 399],
|
||||
[1639, 469],
|
||||
[1568, 582],
|
||||
[1402, 507],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1639, 469],
|
||||
[1650, 557],
|
||||
[1597, 631],
|
||||
[1568, 582],
|
||||
],
|
||||
shade: 0.57,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1402, 507],
|
||||
[1568, 582],
|
||||
[1597, 631],
|
||||
[1414, 583],
|
||||
],
|
||||
shade: 0.66,
|
||||
outline: [
|
||||
[1402, 507],
|
||||
[1568, 582],
|
||||
[1597, 631],
|
||||
[1561, 637],
|
||||
[1444, 621],
|
||||
[1414, 583],
|
||||
],
|
||||
},
|
||||
// Rear rib, left and right of the chest opening.
|
||||
{
|
||||
plane: [
|
||||
[1021, 505],
|
||||
[1129, 484],
|
||||
[1118, 501],
|
||||
[1009, 520],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1009, 520],
|
||||
[1118, 501],
|
||||
[1112, 515],
|
||||
[1006, 535],
|
||||
],
|
||||
shade: 0.65,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1191, 486],
|
||||
[1300, 510],
|
||||
[1289, 530],
|
||||
[1181, 510],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1289, 530],
|
||||
[1300, 510],
|
||||
[1310, 611],
|
||||
[1296, 621],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
|
||||
{
|
||||
plane: [
|
||||
[1052, 514],
|
||||
[1159, 519],
|
||||
[1129, 553],
|
||||
[992, 548],
|
||||
],
|
||||
outline: [
|
||||
[1015, 535],
|
||||
[1052, 520],
|
||||
[1094, 522],
|
||||
[1110, 514],
|
||||
[1159, 519],
|
||||
[1129, 553],
|
||||
[992, 548],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[992, 548],
|
||||
[1129, 553],
|
||||
[1137, 575],
|
||||
[987, 567],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1159, 519],
|
||||
[1170, 560],
|
||||
[1137, 575],
|
||||
[1129, 553],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1180, 513],
|
||||
[1289, 551],
|
||||
[1274, 575],
|
||||
[1159, 538],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1274, 575],
|
||||
[1289, 551],
|
||||
[1298, 617],
|
||||
[1279, 632],
|
||||
],
|
||||
shade: 0.62,
|
||||
},
|
||||
// Closest right rib; its downturned end stops above the ground.
|
||||
{
|
||||
plane: [
|
||||
[1157, 542],
|
||||
[1261, 581],
|
||||
[1243, 602],
|
||||
[1141, 564],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1141, 564],
|
||||
[1243, 602],
|
||||
[1255, 641],
|
||||
[1148, 605],
|
||||
],
|
||||
shade: 0.7,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1243, 602],
|
||||
[1261, 581],
|
||||
[1270, 627],
|
||||
[1255, 641],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
// Foreground rib and spine.
|
||||
{
|
||||
plane: [
|
||||
[1000, 566],
|
||||
[1140, 581],
|
||||
[1107, 604],
|
||||
[960, 589],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[960, 589],
|
||||
[1107, 604],
|
||||
[1112, 680],
|
||||
[968, 622],
|
||||
],
|
||||
shade: 0.6,
|
||||
outline: [
|
||||
[960, 589],
|
||||
[1107, 604],
|
||||
[1112, 680],
|
||||
[1070, 681],
|
||||
[1066, 619],
|
||||
[968, 611],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1107, 604],
|
||||
[1140, 581],
|
||||
[1160, 669],
|
||||
[1112, 680],
|
||||
],
|
||||
shade: 0.5,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[986, 611],
|
||||
[1070, 617],
|
||||
[967, 684],
|
||||
[874, 671],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1070, 617],
|
||||
[1074, 668],
|
||||
[1060, 684],
|
||||
[967, 684],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
]
|
||||
@ -4,6 +4,10 @@ import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
|
||||
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
|
||||
import originalUrl from '@/assets/about-scene/victory-master.jpg'
|
||||
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
|
||||
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
|
||||
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
|
||||
|
||||
import { witherShieldSurfaces } from './wither-shield'
|
||||
|
||||
type Point = [number, number]
|
||||
type Vertex = [number, number, number]
|
||||
@ -17,11 +21,16 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
const original = new Image()
|
||||
const clean = new Image()
|
||||
const correctedPose = new Image()
|
||||
const rebuiltWither = new Image()
|
||||
const removedBeams = new Image()
|
||||
const swordTexture = new Image()
|
||||
const cloudTexture = new Image()
|
||||
const moonTexture = new Image()
|
||||
const W = 2048,
|
||||
H = W / 3,
|
||||
artworkWidth = 2172,
|
||||
artworkHeight = 724,
|
||||
artworkScale = artworkWidth / W,
|
||||
TAU = Math.PI * 2
|
||||
let time = 0,
|
||||
last = 0,
|
||||
@ -36,7 +45,8 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
layer.height = Math.ceil(h)
|
||||
return layer
|
||||
}
|
||||
const base = makeLayer()
|
||||
const base = makeLayer(artworkWidth, artworkHeight)
|
||||
const shield = makeLayer(artworkWidth, artworkHeight)
|
||||
const star = makeLayer(180, 180)
|
||||
const starFace = makeLayer(180, 180)
|
||||
const swordFace = makeLayer(128, 128)
|
||||
@ -87,86 +97,6 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
I: '#fff6da',
|
||||
S: '#fffef1',
|
||||
}
|
||||
const shieldSurfaces: Point[][] = [
|
||||
[
|
||||
[986, 442],
|
||||
[1124, 466],
|
||||
[1096, 541],
|
||||
[982, 532],
|
||||
],
|
||||
[
|
||||
[1110, 462],
|
||||
[1304, 482],
|
||||
[1258, 625],
|
||||
[1000, 605],
|
||||
],
|
||||
[
|
||||
[1304, 482],
|
||||
[1379, 504],
|
||||
[1390, 614],
|
||||
[1258, 625],
|
||||
],
|
||||
[
|
||||
[1328, 481],
|
||||
[1428, 424],
|
||||
[1472, 446],
|
||||
[1390, 523],
|
||||
],
|
||||
[
|
||||
[1014, 603],
|
||||
[1170, 626],
|
||||
[1136, 683],
|
||||
[869, 683],
|
||||
],
|
||||
[
|
||||
[792, 481],
|
||||
[950, 451],
|
||||
[987, 550],
|
||||
[835, 604],
|
||||
],
|
||||
[
|
||||
[792, 481],
|
||||
[835, 604],
|
||||
[821, 625],
|
||||
[780, 533],
|
||||
],
|
||||
[
|
||||
[835, 604],
|
||||
[987, 550],
|
||||
[974, 589],
|
||||
[821, 625],
|
||||
],
|
||||
[
|
||||
[1165, 357],
|
||||
[1301, 355],
|
||||
[1247, 471],
|
||||
[1108, 441],
|
||||
],
|
||||
[
|
||||
[1301, 355],
|
||||
[1358, 419],
|
||||
[1311, 500],
|
||||
[1247, 471],
|
||||
],
|
||||
[
|
||||
[1483, 398],
|
||||
[1626, 470],
|
||||
[1558, 590],
|
||||
[1404, 514],
|
||||
],
|
||||
[
|
||||
[1626, 470],
|
||||
[1655, 549],
|
||||
[1606, 637],
|
||||
[1558, 590],
|
||||
],
|
||||
[
|
||||
[1404, 514],
|
||||
[1558, 590],
|
||||
[1606, 637],
|
||||
[1449, 592],
|
||||
],
|
||||
]
|
||||
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
|
||||
const shieldEvents = [
|
||||
[0, 0.68, 0.48],
|
||||
@ -259,10 +189,12 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
function prepare() {
|
||||
// Restore the sharp master for all unaffected materials, anatomy and terrain.
|
||||
const b = base.getContext('2d')!
|
||||
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
b.imageSmoothingEnabled = false
|
||||
b.drawImage(original, 0, 0, W, H)
|
||||
const sky = makeLayer(),
|
||||
const sky = makeLayer(artworkWidth, artworkHeight),
|
||||
skyCtx = sky.getContext('2d')!
|
||||
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
skyCtx.drawImage(clean, 0, 0, W, H)
|
||||
skyCtx.globalCompositeOperation = 'destination-in'
|
||||
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
|
||||
@ -270,7 +202,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
skyFade.addColorStop(1, 'transparent')
|
||||
skyCtx.fillStyle = skyFade
|
||||
skyCtx.fillRect(0, 0, W, 442)
|
||||
b.drawImage(sky, 0, 0)
|
||||
b.drawImage(sky, 0, 0, W, H)
|
||||
const restore = (source: HTMLImageElement, polygon: Point[]) => {
|
||||
b.save()
|
||||
b.beginPath()
|
||||
@ -341,7 +273,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
ms.fillRect(-140, -135, 280, 270)
|
||||
ms.restore()
|
||||
b.drawImage(moonSky, 193, -20)
|
||||
// Use original sharp head faces, preserving their exact silhouette without a sky border.
|
||||
// Restore the sharp skull planes covered by the sky plate.
|
||||
restore(original, [
|
||||
[1159, 355],
|
||||
[1318, 352],
|
||||
@ -350,7 +282,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
[1246, 474],
|
||||
[1107, 443],
|
||||
])
|
||||
// Local broken-sternum and old-blade removal; the unaffected ribs stay from the master.
|
||||
// Remove the old painted blade without resampling the rest of the creature.
|
||||
restore(clean, [
|
||||
[975, 339],
|
||||
[1002, 334],
|
||||
@ -365,7 +297,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
[1053, 502],
|
||||
[1006, 476],
|
||||
])
|
||||
// Remove the two baked blue lightning remnants without replacing the whole Wither.
|
||||
// Retain the previously approved removal of baked lightning below the skulls.
|
||||
restore(clean, [
|
||||
[900, 595],
|
||||
[948, 572],
|
||||
@ -386,6 +318,49 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
[1318, 525],
|
||||
[1320, 470],
|
||||
])
|
||||
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
|
||||
// ribs, character and background retain their original sharp source pixels.
|
||||
restore(rebuiltWither, [
|
||||
[963, 423],
|
||||
[1014, 417],
|
||||
[1049, 431],
|
||||
[1074, 479],
|
||||
[1066, 495],
|
||||
[1017, 511],
|
||||
[985, 524],
|
||||
[963, 466],
|
||||
])
|
||||
restore(rebuiltWither, [
|
||||
[1360, 415],
|
||||
[1392, 414],
|
||||
[1429, 427],
|
||||
[1448, 445],
|
||||
[1405, 506],
|
||||
[1398, 532],
|
||||
[1355, 528],
|
||||
[1319, 514],
|
||||
[1324, 494],
|
||||
])
|
||||
// Replace only the removed beam silhouettes with the newly exposed background.
|
||||
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
|
||||
restore(removedBeams, [
|
||||
[976, 480],
|
||||
[1146, 462],
|
||||
[1152, 488],
|
||||
[1124, 508],
|
||||
[999, 530],
|
||||
[990, 542],
|
||||
])
|
||||
restore(removedBeams, [
|
||||
[1294, 489],
|
||||
[1404, 498],
|
||||
[1445, 548],
|
||||
[1452, 629],
|
||||
[1402, 621],
|
||||
[1305, 615],
|
||||
[1299, 549],
|
||||
[1283, 521],
|
||||
])
|
||||
restore(correctedPose, [
|
||||
[811, 377],
|
||||
[951, 377],
|
||||
@ -673,57 +648,70 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
const level = shieldLevel(t)
|
||||
canvas.dataset.shieldIntensity = level.toFixed(3)
|
||||
if (level < 0.003) return
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
const surface = shield.getContext('2d')!
|
||||
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
surface.clearRect(0, 0, W, H + 1)
|
||||
surface.save()
|
||||
surface.globalCompositeOperation = 'source-over'
|
||||
const trace = (points: Point[]) => {
|
||||
surface.beginPath()
|
||||
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
|
||||
surface.closePath()
|
||||
}
|
||||
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, W, H)
|
||||
wound.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
|
||||
ctx.closePath()
|
||||
ctx.clip('evenodd')
|
||||
shieldSurfaces.forEach((quad, index) => {
|
||||
surface.beginPath()
|
||||
surface.rect(0, 0, W, H)
|
||||
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
|
||||
surface.closePath()
|
||||
surface.clip('evenodd')
|
||||
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
|
||||
const map = (u: number, v: number): Point => [
|
||||
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
|
||||
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
|
||||
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
|
||||
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
|
||||
]
|
||||
const alpha = level * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
|
||||
path(quad)
|
||||
ctx.fillStyle = `rgba(55,126,203,${alpha * 0.57})`
|
||||
ctx.fill()
|
||||
ctx.save()
|
||||
path(quad)
|
||||
ctx.clip()
|
||||
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
|
||||
trace(outline)
|
||||
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
|
||||
surface.fill()
|
||||
surface.save()
|
||||
trace(outline)
|
||||
surface.clip()
|
||||
for (let row = 0; row < 8; row++)
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const grain = rand(row * 17 + col * 5 + index * 139)
|
||||
if (grain < 0.56) continue
|
||||
path([
|
||||
trace([
|
||||
map(col / 8, row / 8),
|
||||
map((col + 1) / 8, row / 8),
|
||||
map((col + 1) / 8, (row + 1) / 8),
|
||||
map(col / 8, (row + 1) / 8),
|
||||
])
|
||||
ctx.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
|
||||
ctx.fill()
|
||||
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
|
||||
surface.fill()
|
||||
}
|
||||
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
|
||||
for (let band = -1; band < 3; band++)
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
|
||||
const height = 0.034 + rand(col * 3 + band + index) * 0.022
|
||||
path([
|
||||
trace([
|
||||
map(col / 8, v),
|
||||
map((col + 1) / 8, v),
|
||||
map((col + 1) / 8, v + height),
|
||||
map(col / 8, v + height),
|
||||
])
|
||||
ctx.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
|
||||
ctx.fill()
|
||||
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
|
||||
surface.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
surface.restore()
|
||||
})
|
||||
surface.restore()
|
||||
// Composite the fitted faces once, without bright overlaps at shared bone edges.
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
ctx.drawImage(shield, 0, 0, W, H)
|
||||
ctx.restore()
|
||||
}
|
||||
function drawClouds(elapsed: number) {
|
||||
@ -773,9 +761,10 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
const cycle = (t * TAU) / 24
|
||||
ctx.save()
|
||||
ctx.translate(W / 2, H / 2)
|
||||
ctx.scale(1.008, 1.008)
|
||||
ctx.translate(-W / 2 + Math.sin(cycle) * 1.6, -H / 2 + Math.sin(cycle * 2) * 0.7)
|
||||
// Keep the static plate pixel-stable; animate only the independent effects.
|
||||
// The full-resolution master is sampled once, without a floating camera zoom.
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(base, 0, 0, W, H)
|
||||
drawMoon()
|
||||
drawClouds(elapsed)
|
||||
@ -883,7 +872,10 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
if (destroyed) return
|
||||
const width = canvas.getBoundingClientRect().width
|
||||
if (width <= 0) return
|
||||
canvas.width = Math.min(W, Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))))
|
||||
canvas.width = Math.min(
|
||||
artworkWidth,
|
||||
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
|
||||
)
|
||||
canvas.height = Math.round(canvas.width / 3)
|
||||
cloudField?.resize(canvas.width, canvas.height)
|
||||
if (ready) draw(time)
|
||||
@ -903,7 +895,16 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
|
||||
img.src = url
|
||||
})
|
||||
const imageAssets = [original, clean, swordTexture, correctedPose, cloudTexture, moonTexture]
|
||||
const imageAssets = [
|
||||
original,
|
||||
clean,
|
||||
swordTexture,
|
||||
correctedPose,
|
||||
cloudTexture,
|
||||
moonTexture,
|
||||
rebuiltWither,
|
||||
removedBeams,
|
||||
]
|
||||
const loading = Promise.all([
|
||||
loaded(original, originalUrl),
|
||||
loaded(clean, cleanUrl),
|
||||
@ -911,6 +912,8 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
loaded(correctedPose, correctedPoseUrl),
|
||||
loaded(cloudTexture, cloudTextureUrl),
|
||||
loaded(moonTexture, moonTextureUrl),
|
||||
loaded(rebuiltWither, rebuiltWitherUrl),
|
||||
loaded(removedBeams, removedBeamsUrl),
|
||||
])
|
||||
.then(() => {
|
||||
if (destroyed) return
|
||||
@ -941,7 +944,7 @@ export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
reducedMotion.removeEventListener('change', updatePlayback)
|
||||
cloudField?.dispose()
|
||||
cloudField = undefined
|
||||
for (const layer of [base, star, starFace, swordFace, grippingFingers]) {
|
||||
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
|
||||
layer.width = 1
|
||||
layer.height = 1
|
||||
}
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
|
||||
defineProps<{ src: string; name: string; href?: string }>()
|
||||
const emit = defineEmits<{ activate: [] }>()
|
||||
const holding = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let origin = { x: 0, y: 0 }
|
||||
let suppressClick = false
|
||||
|
||||
function cancel() {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
holding.value = false
|
||||
}
|
||||
function begin() {
|
||||
cancel()
|
||||
suppressClick = false
|
||||
holding.value = true
|
||||
timer = setTimeout(() => {
|
||||
cancel()
|
||||
suppressClick = true
|
||||
emit('activate')
|
||||
}, 800)
|
||||
}
|
||||
function pointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0 || !event.isPrimary) return
|
||||
origin = { x: event.clientX, y: event.clientY }
|
||||
begin()
|
||||
}
|
||||
function pointerMove(event: PointerEvent) {
|
||||
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
|
||||
}
|
||||
function click(event: MouseEvent) {
|
||||
if (!suppressClick) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressClick = false
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
if (!event.repeat) begin()
|
||||
}
|
||||
}
|
||||
window.addEventListener('blur', cancel)
|
||||
onScopeDispose(() => {
|
||||
cancel()
|
||||
window.removeEventListener('blur', cancel)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mine-avatar"
|
||||
:class="{ holding }"
|
||||
@pointerdown="pointerDown"
|
||||
@pointermove="pointerMove"
|
||||
@pointerup="cancel"
|
||||
@pointercancel="cancel"
|
||||
@pointerleave="cancel"
|
||||
@blur="cancel"
|
||||
@click="click"
|
||||
@contextmenu.prevent
|
||||
@dragstart.prevent
|
||||
@keydown="keydown"
|
||||
@keyup.space.prevent="cancel"
|
||||
>
|
||||
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mine-avatar {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mine-avatar.holding {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.mine-avatar:focus-visible {
|
||||
outline: 2px solid var(--color-contrast);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
import type { Puzzle } from './engine'
|
||||
import { messages } from './messages'
|
||||
@ -20,7 +20,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const viewport = ref<HTMLElement>()
|
||||
const grid = ref<HTMLElement>()
|
||||
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
|
||||
const brushCursor = ref({ x: 0, y: 0, color: '', visible: false })
|
||||
const large = computed(() => props.puzzle.difficulty !== 'easy')
|
||||
const visibleCells = computed(() =>
|
||||
props.puzzle.answer.flatMap((color, i) =>
|
||||
@ -85,6 +87,38 @@ function onWheel(event: WheelEvent) {
|
||||
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
|
||||
}
|
||||
|
||||
function moveBrushCursor(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') {
|
||||
hideBrushCursor()
|
||||
return
|
||||
}
|
||||
const cell = (event.target as Element | null)?.closest<HTMLButtonElement>('.mine-cell')
|
||||
const visible = Boolean(
|
||||
props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'),
|
||||
)
|
||||
brushCursor.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '',
|
||||
visible,
|
||||
}
|
||||
}
|
||||
|
||||
function hideBrushCursor() {
|
||||
brushCursor.value.visible = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.playing, props.selected] as const,
|
||||
([playing, selected]) => {
|
||||
if (!playing || selected < 0) hideBrushCursor()
|
||||
else if (brushCursor.value.visible && grid.value) {
|
||||
brushCursor.value.color = getComputedStyle(grid.value).color
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function label(index: number) {
|
||||
const values = {
|
||||
row: Math.floor(index / props.puzzle.size) + 1,
|
||||
@ -116,10 +150,18 @@ defineExpose({
|
||||
<div class="mine-navigation" :class="{ 'mine-large': large }">
|
||||
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
|
||||
<div
|
||||
ref="grid"
|
||||
class="mine-grid"
|
||||
:class="{ 'mine-grid-small': !large }"
|
||||
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
|
||||
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
|
||||
:style="{
|
||||
'--mine-size': puzzle.size,
|
||||
'--mine-cell-size': `${40 * zoom}px`,
|
||||
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
|
||||
}"
|
||||
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
|
||||
@pointermove="moveBrushCursor"
|
||||
@pointerleave="hideBrushCursor"
|
||||
@pointercancel="hideBrushCursor"
|
||||
>
|
||||
<button
|
||||
v-for="(_, i) in puzzle.answer"
|
||||
@ -148,6 +190,24 @@ defineExpose({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<svg
|
||||
v-show="brushCursor.visible"
|
||||
class="mine-brush-cursor"
|
||||
:style="{
|
||||
left: `${brushCursor.x}px`,
|
||||
top: `${brushCursor.y}px`,
|
||||
color: brushCursor.color,
|
||||
}"
|
||||
viewBox="0 0 1024 1024"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"
|
||||
/>
|
||||
</svg>
|
||||
</Teleport>
|
||||
<div v-if="large" class="mine-overview">
|
||||
<svg
|
||||
class="mine-map"
|
||||
@ -244,10 +304,22 @@ defineExpose({
|
||||
color: var(--color-contrast);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: crosshair;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
|
||||
cursor: none;
|
||||
}
|
||||
.mine-brush-cursor {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%));
|
||||
pointer-events: none;
|
||||
transform: translate(-16%, -84%);
|
||||
}
|
||||
.mine-cell:hover:not(:disabled) {
|
||||
background: var(--surface-5);
|
||||
border-color: var(--mine-ink);
|
||||
@ -320,4 +392,12 @@ defineExpose({
|
||||
width: 4.5rem;
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mine-brush-cursor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage: 'Your last next launcher.',
|
||||
},
|
||||
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
|
||||
homeWidgetsTitle: {
|
||||
id: 'app.onboarding.home-widgets.title',
|
||||
defaultMessage: 'Your Home, your layout',
|
||||
},
|
||||
homeWidgetsDescription: {
|
||||
id: 'app.onboarding.home-widgets.description',
|
||||
defaultMessage:
|
||||
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
|
||||
},
|
||||
homeCustomizeTitle: {
|
||||
id: 'app.onboarding.home-customize.title',
|
||||
defaultMessage: 'Arrange it your way',
|
||||
},
|
||||
homeCustomizeDescription: {
|
||||
id: 'app.onboarding.home-customize.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
|
||||
},
|
||||
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
|
||||
discoverDescription: {
|
||||
id: 'app.onboarding.discover.description',
|
||||
@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
|
||||
},
|
||||
homeLayoutTitle: {
|
||||
id: 'app.onboarding.home-layout.title',
|
||||
defaultMessage: 'Change the amount of detail',
|
||||
defaultMessage: 'Switch your home',
|
||||
},
|
||||
homeLayoutDescription: {
|
||||
id: 'app.onboarding.home-layout.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
|
||||
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
|
||||
},
|
||||
continueArea: {
|
||||
id: 'app.onboarding.action.continue-area',
|
||||
@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
|
||||
skinsDescription: {
|
||||
id: 'app.onboarding.skins.description',
|
||||
defaultMessage:
|
||||
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
|
||||
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
|
||||
},
|
||||
clickSkins: {
|
||||
id: 'app.onboarding.action.click-skins',
|
||||
@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
|
||||
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
||||
skinsPageDescription: {
|
||||
id: 'app.onboarding.skins-page.description',
|
||||
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
|
||||
},
|
||||
accountTitle: {
|
||||
id: 'app.onboarding.account.title',
|
||||
defaultMessage: 'Accounts, on your schedule',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.onboarding.account.description',
|
||||
defaultMessage:
|
||||
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
|
||||
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
|
||||
},
|
||||
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
||||
downloadsDescription: {
|
||||
@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage:
|
||||
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
|
||||
},
|
||||
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
|
||||
languageDescription: {
|
||||
id: 'app.onboarding.language.description',
|
||||
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
|
||||
},
|
||||
translationTitle: {
|
||||
id: 'app.onboarding.translation.title',
|
||||
defaultMessage: 'Translation, the Starlight way',
|
||||
},
|
||||
translationDescription: {
|
||||
id: 'app.onboarding.translation.description',
|
||||
defaultMessage:
|
||||
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
|
||||
},
|
||||
aiTitle: {
|
||||
id: 'app.onboarding.ai.title',
|
||||
defaultMessage: 'Bring your own AI provider',
|
||||
},
|
||||
aiDescription: {
|
||||
id: 'app.onboarding.ai.description',
|
||||
defaultMessage:
|
||||
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
|
||||
},
|
||||
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
|
||||
javaDescription: {
|
||||
id: 'app.onboarding.java.description',
|
||||
defaultMessage:
|
||||
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
|
||||
},
|
||||
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
|
||||
defaultsDescription: {
|
||||
id: 'app.onboarding.defaults.description',
|
||||
@ -204,12 +149,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage:
|
||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||
},
|
||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
defaultMessage:
|
||||
'Choose when Starlight checks for updates and whether it installs them for you.',
|
||||
},
|
||||
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||
libraryDescription: {
|
||||
@ -228,7 +167,7 @@ export const onboardingMessages = defineMessages({
|
||||
libraryPageDescription: {
|
||||
id: 'app.onboarding.library-page.description',
|
||||
defaultMessage:
|
||||
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
|
||||
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
|
||||
},
|
||||
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
||||
createDescription: {
|
||||
@ -247,7 +186,7 @@ export const onboardingMessages = defineMessages({
|
||||
instanceModeDescription: {
|
||||
id: 'app.onboarding.instance-mode.description',
|
||||
defaultMessage:
|
||||
'Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings.',
|
||||
'StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player.',
|
||||
},
|
||||
creationDescription: {
|
||||
id: 'app.onboarding.creation.description',
|
||||
@ -320,7 +259,7 @@ export const onboardingMessages = defineMessages({
|
||||
labDescription: {
|
||||
id: 'app.onboarding.lab.description',
|
||||
defaultMessage:
|
||||
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
|
||||
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
|
||||
},
|
||||
clickLab: {
|
||||
id: 'app.onboarding.action.click-lab',
|
||||
@ -333,63 +272,7 @@ export const onboardingMessages = defineMessages({
|
||||
labToolsDescription: {
|
||||
id: 'app.onboarding.lab-tools.description',
|
||||
defaultMessage:
|
||||
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
|
||||
},
|
||||
openGradientText: {
|
||||
id: 'app.onboarding.action.open-gradient-text',
|
||||
defaultMessage: 'Open Gradient text generator to continue',
|
||||
},
|
||||
labEditorTitle: {
|
||||
id: 'app.onboarding.lab-editor.title',
|
||||
defaultMessage: 'Build and copy in one place',
|
||||
},
|
||||
labEditorDescription: {
|
||||
id: 'app.onboarding.lab-editor.description',
|
||||
defaultMessage:
|
||||
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
|
||||
},
|
||||
labSeedMapTitle: {
|
||||
id: 'app.onboarding.lab-seed-map.title',
|
||||
defaultMessage: 'Find a world before you load it',
|
||||
},
|
||||
labSeedMapDescription: {
|
||||
id: 'app.onboarding.lab-seed-map.description',
|
||||
defaultMessage:
|
||||
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
|
||||
},
|
||||
returnToLab: {
|
||||
id: 'app.onboarding.action.return-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
openSeedMap: {
|
||||
id: 'app.onboarding.action.open-seed-map',
|
||||
defaultMessage: 'Open Seed map to continue',
|
||||
},
|
||||
openSchematicWorkshop: {
|
||||
id: 'app.onboarding.action.open-schematic-workshop',
|
||||
defaultMessage: 'Open Schematic workshop to continue',
|
||||
},
|
||||
openRecipeGenerator: {
|
||||
id: 'app.onboarding.action.open-recipe-generator',
|
||||
defaultMessage: 'Open Recipe generator to continue',
|
||||
},
|
||||
labRecipeGeneratorTitle: {
|
||||
id: 'app.onboarding.lab-recipe-generator.title',
|
||||
defaultMessage: 'Craft data pack recipes',
|
||||
},
|
||||
labRecipeGeneratorDescription: {
|
||||
id: 'app.onboarding.lab-recipe-generator.description',
|
||||
defaultMessage:
|
||||
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
|
||||
},
|
||||
labSchematicTitle: {
|
||||
id: 'app.onboarding.lab-schematic.title',
|
||||
defaultMessage: 'Inspect a build before placing it',
|
||||
},
|
||||
labSchematicDescription: {
|
||||
id: 'app.onboarding.lab-schematic.description',
|
||||
defaultMessage:
|
||||
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
|
||||
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
|
||||
},
|
||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
||||
@ -457,18 +340,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.start,
|
||||
),
|
||||
),
|
||||
inspect(
|
||||
'home-widget-grid',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeWidgetsTitle,
|
||||
onboardingMessages.homeWidgetsDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-widget-customize',
|
||||
'home-widget-customize',
|
||||
onboardingMessages.homeCustomizeTitle,
|
||||
onboardingMessages.homeCustomizeDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
@ -523,16 +394,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.skinsPageTitle,
|
||||
onboardingMessages.skinsPageDescription,
|
||||
),
|
||||
step(
|
||||
'account',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.accountTitle,
|
||||
onboardingMessages.accountDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
control('account-entry'),
|
||||
),
|
||||
step(
|
||||
'lab-navigation',
|
||||
'navigate',
|
||||
@ -549,90 +410,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.labToolsTitle,
|
||||
onboardingMessages.labToolsDescription,
|
||||
),
|
||||
step(
|
||||
'lab-gradient-text-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
onboardingMessages.openGradientText,
|
||||
),
|
||||
control('lab-gradient-text-card', '/lab/gradient-text'),
|
||||
),
|
||||
inspect(
|
||||
'lab-gradient-text-editor',
|
||||
'lab-gradient-text-editor',
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-seed-map-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.openSeedMap,
|
||||
),
|
||||
control('lab-seed-map-card', '/lab/seed-map'),
|
||||
),
|
||||
inspect(
|
||||
'lab-seed-map-workspace',
|
||||
'seed-map-workspace',
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.openSchematicWorkshop,
|
||||
),
|
||||
control('lab-schematic-preview-card', '/lab/schematic-preview'),
|
||||
),
|
||||
inspect(
|
||||
'lab-schematic-workspace',
|
||||
'schematic-preview-workspace',
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
),
|
||||
step(
|
||||
'lab-recipe-generator-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
onboardingMessages.openRecipeGenerator,
|
||||
),
|
||||
control('lab-recipe-generator-card', '/lab/recipe-generator'),
|
||||
),
|
||||
inspect(
|
||||
'lab-recipe-generator-workspace',
|
||||
'recipe-generator-workspace',
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
),
|
||||
step(
|
||||
'downloads-navigation',
|
||||
'navigate',
|
||||
@ -708,6 +485,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
{
|
||||
targetId: 'creation-methods',
|
||||
branchByTarget: {
|
||||
'creation-method-starlight': { next: 'complete' },
|
||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
||||
'creation-method-import': { next: 'complete' },
|
||||
},
|
||||
|
||||
@ -5,7 +5,6 @@ import { getVersion } from '@tauri-apps/api/app'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||
|
||||
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
|
||||
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
|
||||
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
@ -17,7 +16,7 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
|
||||
const { formatMessage } = useVIntl()
|
||||
const version = await getVersion()
|
||||
const experienceHost = ref<HTMLElement>()
|
||||
const activeMemberExperience = shallowRef<AboutMemberExperience>()
|
||||
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
|
||||
const pressingMemberName = ref<string>()
|
||||
let longPressTimer: number | undefined
|
||||
let pressStart = { x: 0, y: 0 }
|
||||
@ -41,9 +40,13 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
|
||||
pressStart = { x: event.clientX, y: event.clientY }
|
||||
pressingMemberName.value = member.name
|
||||
longPressTimer = window.setTimeout(async () => {
|
||||
activeMemberExperience.value = experience
|
||||
suppressNextMemberClick = true
|
||||
cancelMemberLongPress()
|
||||
if (experience.kind === 'color-mine') {
|
||||
openColorMine()
|
||||
return
|
||||
}
|
||||
activeMemberExperience.value = experience
|
||||
await nextTick()
|
||||
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, experience.longPressDuration)
|
||||
@ -300,56 +303,37 @@ const messages = defineMessages({
|
||||
{{ formatMessage(messages.developmentTeam) }}
|
||||
</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<template v-for="member in teamMembers" :key="member.name">
|
||||
<div
|
||||
v-if="member.name === 'Disy920'"
|
||||
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
|
||||
>
|
||||
<ColorMineAvatar
|
||||
:src="member.avatarUrl"
|
||||
:name="member.name"
|
||||
:href="member.url"
|
||||
@activate="openColorMine"
|
||||
/>
|
||||
<a
|
||||
:href="member.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
|
||||
member.name
|
||||
}}</span>
|
||||
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
v-else
|
||||
:href="member.url ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
|
||||
@pointerdown="startMemberLongPress(member, $event)"
|
||||
@pointermove="moveMemberLongPress"
|
||||
@pointerup="cancelMemberLongPress"
|
||||
@pointerleave="cancelMemberLongPress"
|
||||
@click="handleMemberClick"
|
||||
@contextmenu="handleMemberContextMenu(member, $event)"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.name"
|
||||
size="2.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
|
||||
{{ member.name }}
|
||||
</span>
|
||||
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||
</a>
|
||||
</template>
|
||||
<a
|
||||
v-for="member in teamMembers"
|
||||
:key="member.name"
|
||||
:href="member.url ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
|
||||
:class="{ 'member-card-holding': pressingMemberName === member.name }"
|
||||
@pointerdown="startMemberLongPress(member, $event)"
|
||||
@pointermove="moveMemberLongPress"
|
||||
@pointerup="cancelMemberLongPress"
|
||||
@pointercancel="cancelMemberLongPress"
|
||||
@pointerleave="cancelMemberLongPress"
|
||||
@blur="cancelMemberLongPress"
|
||||
@click="handleMemberClick"
|
||||
@contextmenu="handleMemberContextMenu(member, $event)"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<Avatar
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.name"
|
||||
size="2.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
|
||||
{{ member.name }}
|
||||
</span>
|
||||
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<details class="group pt-4 about-settings-details">
|
||||
@ -417,6 +401,11 @@ const messages = defineMessages({
|
||||
padding: var(--gap-lg);
|
||||
}
|
||||
|
||||
.member-card-holding {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.about-page :deep(.rounded-xl.bg-surface-4) {
|
||||
border: 1px solid
|
||||
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
||||
|
||||
@ -1,21 +1,31 @@
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import AboutEasterEgg from '../AboutEasterEgg.vue'
|
||||
|
||||
export type AboutMemberExperience = {
|
||||
component: Component
|
||||
longPressDuration: number
|
||||
}
|
||||
export type AboutMemberExperience =
|
||||
| {
|
||||
kind: 'scene'
|
||||
component: Component
|
||||
longPressDuration: number
|
||||
}
|
||||
| {
|
||||
kind: 'color-mine'
|
||||
longPressDuration: number
|
||||
}
|
||||
|
||||
// 长按「关于」页成员名字触发的彩蛋体验。
|
||||
// 长按「关于」页成员卡片触发的彩蛋体验。
|
||||
// 下界之星合成彩蛋(AboutEasterEgg.vue),由长按成员名 / 暗号 / Konami 秘技触发。
|
||||
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
|
||||
const memberExperiences: Record<string, AboutMemberExperience> = {
|
||||
'easter-egg': {
|
||||
kind: 'scene',
|
||||
component: AboutEasterEgg,
|
||||
longPressDuration: 800,
|
||||
},
|
||||
'color-mine': {
|
||||
kind: 'color-mine',
|
||||
longPressDuration: 800,
|
||||
},
|
||||
}
|
||||
|
||||
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {
|
||||
|
||||
Reference in New Issue
Block a user