feat: add Starlight Mine Chromatic Realms easter egg

This commit is contained in:
2026-09-13 20:31:54 +08:00
parent f8aa654198
commit ccc7f7e16b
15 changed files with 1983 additions and 42 deletions

View File

@ -0,0 +1,93 @@
<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>

View File

@ -0,0 +1,323 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
const props = defineProps<{
puzzle: Puzzle
revealed: boolean[]
selected: number
playing: boolean
lost: boolean
mistake: number
zoom: number
}>()
const emit = defineEmits<{
paint: [index: number]
'update:zoom': [value: number]
}>()
const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
props.revealed[i] || props.lost ? [{ color, i }] : [],
),
)
const letter = (color: number) => String.fromCharCode(65 + color)
function updateView() {
const el = viewport.value
if (!el) return
const size = props.puzzle.size
view.value = {
left: (el.scrollLeft / el.scrollWidth) * size,
top: (el.scrollTop / el.scrollHeight) * size,
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
}
}
async function setView(left: number, top: number) {
await nextTick()
viewport.value?.scrollTo(left, top)
updateView()
}
async function centerFirstClue() {
await nextTick()
const el = viewport.value
if (!el || !large.value) return setView(0, 0)
const i = props.revealed.findIndex(Boolean)
await setView(
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
)
}
function navigate(event: MouseEvent) {
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
const el = viewport.value
if (!el) return
void setView(
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
)
}
async function changeZoom(value: number) {
const el = viewport.value
if (!el) return
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
emit('update:zoom', zoom)
await setView(x, y)
}
function onWheel(event: WheelEvent) {
if (!large.value || !event.ctrlKey) return
event.preventDefault()
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
column: (index % props.puzzle.size) + 1,
}
return props.revealed[index] || props.lost
? formatMessage(messages.revealed, {
...values,
color: letter(props.puzzle.answer[index]),
number: props.puzzle.numbers[index],
})
: formatMessage(messages.hidden, values)
}
let observer: ResizeObserver | undefined
onMounted(() => {
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
})
</script>
<template>
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
class="mine-grid"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
>
<button
v-for="(_, i) in puzzle.answer"
:key="i"
type="button"
class="mine-cell"
:class="{
'mine-open': revealed[i] || lost,
'mine-answer': lost && !revealed[i],
'mine-mistake': mistake === i,
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
}"
:style="
revealed[i] || lost
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
: undefined
"
:disabled="!playing"
:aria-label="label(i)"
@click="emit('paint', i)"
>
<template v-if="revealed[i] || lost">
<span>{{ puzzle.numbers[i] }}</span
><small>{{ letter(puzzle.answer[i]) }}</small>
</template>
</button>
</div>
</div>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
role="img"
:aria-label="formatMessage(messages.map)"
@click="navigate"
>
<rect width="100%" height="100%" fill="var(--surface-1)" />
<rect
v-for="cell in visibleCells"
:key="cell.i"
:x="cell.i % puzzle.size"
:y="Math.floor(cell.i / puzzle.size)"
width="1"
height="1"
:fill="`var(--mine-color-${cell.color})`"
/>
<rect
:x="view.left"
:y="view.top"
:width="view.width"
:height="view.height"
fill="none"
stroke="var(--color-contrast)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="mine-zoom">
<Button
size="sm"
:disabled="zoom <= 0.7"
:aria-label="formatMessage(messages.zoomOut)"
@click="changeZoom(zoom - 0.1)"
></Button
>
<span>{{ Math.round(zoom * 100) }}%</span>
<Button
size="sm"
:disabled="zoom >= 1.6"
:aria-label="formatMessage(messages.zoomIn)"
@click="changeZoom(zoom + 0.1)"
>+</Button
>
</div>
</div>
</div>
</template>
<style scoped>
.mine-navigation {
min-width: 0;
display: grid;
gap: var(--gap-md);
}
.mine-large {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
background: var(--surface-1);
border-radius: var(--radius-sm);
overscroll-behavior: contain;
}
.mine-grid {
display: grid;
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
grid-auto-rows: var(--mine-cell-size);
gap: 3px;
width: max-content;
padding: 3px;
}
.mine-grid-small {
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
grid-auto-rows: auto;
width: min(100%, 26rem, 48vh);
margin: auto;
box-sizing: border-box;
}
.mine-cell {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
border-radius: var(--radius-sm);
background: var(--surface-4);
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: crosshair;
user-select: none;
touch-action: manipulation;
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);
}
.mine-cell:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: -3px;
z-index: 1;
}
.mine-open {
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
border-color: var(--mine-cell-color);
cursor: pointer;
}
.mine-open:hover:not(:disabled) {
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
}
.mine-sampled {
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
}
.mine-cell small {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 0.5rem;
line-height: 1;
}
.mine-answer {
opacity: 0.55;
}
.mine-cell:disabled {
cursor: default;
}
.mine-mistake {
opacity: 1;
outline: 3px solid var(--color-red);
outline-offset: -3px;
}
.mine-overview {
display: flex;
flex-direction: column;
gap: var(--gap-md);
align-items: center;
}
.mine-map {
display: block;
width: 7rem;
height: auto;
aspect-ratio: 1;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
cursor: crosshair;
}
.mine-zoom {
display: flex;
align-items: center;
gap: var(--gap-xs);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.mine-large {
grid-template-columns: minmax(0, 1fr);
}
.mine-overview {
flex-direction: row;
justify-content: space-between;
}
.mine-map {
width: 4.5rem;
}
}
</style>

View File

@ -0,0 +1,482 @@
<script setup lang="ts">
import './palette.css'
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineBoard from './ColorMineBoard.vue'
import { type Difficulty, LEVELS, type Puzzle } from './engine'
import { messages } from './messages'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const board = ref<InstanceType<typeof ColorMineBoard>>()
const prompt = ref<HTMLElement>()
const puzzle = shallowRef<Puzzle>()
const revealed = ref<boolean[]>([])
const selected = ref(-1)
const mistake = ref(-1)
const elapsed = ref(0)
const zoom = ref(1)
const best = ref<number | null>(null)
const difficulty = ref<Difficulty>('easy')
const levels = Object.keys(LEVELS) as Difficulty[]
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
const result = ref<'playing' | 'lost' | 'won'>('playing')
const confirmation = ref<'leave' | 'replace' | null>(null)
const saved = shallowRef<SavedGame | null>(null)
const storageError = ref(false)
const recordError = ref(false)
const needsColor = ref(false)
let nextDifficulty: Difficulty = 'easy'
let worker: Worker | undefined
let active = false
let closing = false
let started = false
let heldView = { left: 0, top: 0 }
let lastTick = performance.now()
const count = computed(() => revealed.value.filter(Boolean).length)
const letter = (color: number) => String.fromCharCode(65 + color)
const timeText = (time: number) => {
const seconds = Math.floor(time / 1000)
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
}
const status = computed(() => {
if (result.value === 'won') return formatMessage(messages.won)
if (result.value === 'lost' && puzzle.value)
return formatMessage(messages.lost, {
row: Math.floor(mistake.value / puzzle.value.size) + 1,
column: (mistake.value % puzzle.value.size) + 1,
selected: letter(selected.value),
correct: letter(puzzle.value.answer[mistake.value]),
})
return selected.value < 0 || needsColor.value
? formatMessage(messages.choose)
: formatMessage(messages.selected, { color: letter(selected.value) })
})
function tick() {
const now = performance.now()
if (
active &&
started &&
screen.value === 'board' &&
result.value === 'playing' &&
!confirmation.value &&
!document.hidden
) {
elapsed.value += now - lastTick
}
lastTick = now
}
let timer: number | undefined
function visibilityChanged() {
lastTick = performance.now()
}
document.addEventListener('visibilitychange', visibilityChanged)
async function show() {
if (active || closing) return
active = true
lastTick = performance.now()
timer = window.setInterval(tick, 250)
modal.value?.show()
loadSaved()
}
function loadSaved() {
storageError.value = false
saved.value = null
try {
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
if (saved.value) {
difficulty.value = saved.value.difficulty
screen.value = 'saved'
} else start(difficulty.value)
} catch {
screen.value = 'saved'
storageError.value = true
}
}
function start(level: Difficulty, resume?: SavedGame) {
worker?.terminate()
confirmation.value = null
storageError.value = false
recordError.value = false
screen.value = 'loading'
difficulty.value = level
started = false
try {
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
type: 'module',
})
worker = pending
pending.onerror = () => {
if (worker !== pending) return
pending.terminate()
worker = undefined
screen.value = 'error'
}
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
if (worker !== pending || !active) return
pending.terminate()
worker = undefined
if (!event.data.puzzle) {
screen.value = 'error'
return
}
const generated = event.data.puzzle
if (resume) {
try {
validateResume(resume, generated)
// Consume the save before playing so losing cannot reload an older, safe state.
localStorage.removeItem(SAVE_KEY)
} catch {
screen.value = 'saved'
storageError.value = true
return
}
}
saved.value = null
puzzle.value = generated
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
selected.value = resume?.selected ?? -1
elapsed.value = resume?.elapsed ?? 0
started = elapsed.value > 0
lastTick = performance.now()
zoom.value = resume?.zoom ?? 1
mistake.value = -1
needsColor.value = false
result.value = 'playing'
screen.value = 'board'
try {
best.value = readBest(localStorage, level)
} catch {
best.value = null
}
await nextTick()
if (resume) await board.value?.setView(resume.left, resume.top)
else await board.value?.centerFirstClue()
}
pending.postMessage({
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
difficulty: level,
})
} catch {
worker?.terminate()
worker = undefined
screen.value = 'error'
}
}
function paint(index: number) {
const current = puzzle.value
if (!current || confirmation.value || result.value !== 'playing') return
if (revealed.value[index]) {
selected.value = current.answer[index]
needsColor.value = false
return
}
if (selected.value < 0) {
needsColor.value = true
return
}
tick()
started = true
if (selected.value !== current.answer[index]) {
mistake.value = index
result.value = 'lost'
return
}
revealed.value[index] = true
if (revealed.value.every(Boolean)) {
result.value = 'won'
try {
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
} catch {
recordError.value = true
}
}
}
async function ask(kind: 'leave' | 'replace') {
tick()
heldView = board.value?.getView() ?? { left: 0, top: 0 }
confirmation.value = kind
await nextTick()
prompt.value?.focus()
}
function requestNew(level: Difficulty = difficulty.value) {
nextDifficulty = level
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
else start(level)
}
function requestClose() {
if (confirmation.value) {
confirmation.value = null
lastTick = performance.now()
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
return
}
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
else void close()
}
async function close() {
if (closing) return
closing = true
active = false
window.clearInterval(timer)
timer = undefined
worker?.terminate()
worker = undefined
await modal.value?.hide()
confirmation.value = null
puzzle.value = undefined
closing = false
}
async function saveAndLeave() {
if (!puzzle.value) return
try {
saveGame(localStorage, {
version: 1,
seed: puzzle.value.seed,
difficulty: puzzle.value.difficulty,
revealed: revealed.value.slice(),
selected: selected.value,
elapsed: elapsed.value,
zoom: zoom.value,
left: heldView.left,
top: heldView.top,
})
await close()
} catch {
storageError.value = true
}
}
function discardSaved() {
try {
localStorage.removeItem(SAVE_KEY)
saved.value = null
start(difficulty.value)
} catch {
storageError.value = true
}
}
function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
requestClose()
}
}
onScopeDispose(() => {
worker?.terminate()
window.clearInterval(timer)
document.removeEventListener('visibilitychange', visibilityChanged)
})
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
:closable="false"
:close-on-esc="false"
actions-divider
@keydown.stop="keydown"
>
<div
class="color-mine"
:style="{
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
}"
>
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
<h3>
{{
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
}}
</h3>
<p>
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
</p>
</div>
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
<div class="mine-toolbar">
<div class="mine-levels">
<Button
v-for="level in levels"
:key="level"
size="sm"
:type="difficulty === level ? 'outlined' : 'base'"
:aria-pressed="difficulty === level"
@click="level !== difficulty && requestNew(level)"
>{{ formatMessage(messages[level]) }}</Button
>
</div>
<span class="mine-time">{{ timeText(elapsed) }}</span>
</div>
<div class="mine-frame">
<p class="mine-status" role="status">{{ status }}</p>
<ColorMineBoard
ref="board"
v-model:zoom="zoom"
:puzzle="puzzle"
:revealed="revealed"
:selected="selected"
:playing="result === 'playing'"
:lost="result === 'lost'"
:mistake="mistake"
@paint="paint"
/>
</div>
<div class="mine-stats">
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
<span v-if="best !== null">{{
formatMessage(messages.best, { time: timeText(best) })
}}</span>
</div>
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
<details class="mine-rules">
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
<p>{{ formatMessage(messages.rules) }}</p>
</details>
</div>
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
<div v-else-if="screen === 'saved'" class="mine-prompt">
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
<p v-if="saved">
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
</p>
</div>
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
</div>
<template #actions>
<div class="mine-actions">
<template v-if="confirmation">
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
<Button v-if="confirmation === 'leave'" @click="close">{{
formatMessage(messages.discard)
}}</Button>
<Button
type="colored"
color="brand"
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
>
{{
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
}}</Button
>
</template>
<template v-else>
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
<template v-if="screen === 'saved'">
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
<Button
type="colored"
color="brand"
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
>
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
>
</template>
<Button
v-else-if="screen !== 'loading'"
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
color="brand"
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
>
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
>
</template>
</div>
</template>
</NewModal>
</template>
<style scoped>
.color-mine {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-play {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-toolbar,
.mine-levels,
.mine-stats,
.mine-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-sm);
}
.mine-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-contrast);
}
.mine-meta,
.mine-stats,
.mine-rules {
color: var(--color-secondary);
font-size: 0.875rem;
}
.mine-stats {
justify-content: space-between;
}
.mine-frame {
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
}
.mine-actions {
justify-content: flex-end;
}
.mine-prompt h3,
.mine-prompt p {
margin: 0 0 var(--gap-md);
}
.mine-rules summary {
cursor: pointer;
}
.mine-rules p {
line-height: 1.6;
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
function connected(board: Board) {
const { cross } = createEngine(board.size, board.colors)
for (let color = 0; color < board.colors; color++) {
const source = board.answer.indexOf(color)
if (source < 0) return false
const visited = new Set([source])
const pending = [source]
while (pending.length) {
for (const neighbor of cross[pending.pop()!]) {
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
visited.add(neighbor)
pending.push(neighbor)
}
}
}
if (visited.size !== board.answer.filter((c) => c === color).length) return false
}
return true
}
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
for (let seed = 1; seed <= 20; seed++) {
const puzzle = generate(seed, difficulty)
const engine = createEngine(puzzle.size, puzzle.colors)
assert.ok(connected(puzzle))
assert.ok(puzzle.clues.some((v) => !v))
assert.ok(
puzzle.clues.filter(Boolean).length >=
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
)
for (let color = 0; color < puzzle.colors; color++) {
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
}
assert.deepEqual(
puzzle.numbers,
puzzle.answer.map(
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
),
)
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
if (difficulty === 'hard') {
for (let i = 0; i < puzzle.clues.length; i++) {
if (
!puzzle.clues[i] ||
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
)
continue
const fewer = puzzle.clues.slice()
fewer[i] = false
assert.ok(
engine.solve(puzzle, fewer).some((v) => !v),
`Redundant clue at ${i}, seed ${seed}`,
)
}
}
}
})
}
test('seeded generation is deterministic for saved games', () => {
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
})
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
const engine = createEngine(3, 2)
const boards: Board[] = []
for (let bits = 1; bits < 511; bits++) {
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
const board = {
size: 3,
colors: 2,
answer,
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
}
if (connected(board)) boards.push(board)
}
for (const board of boards) {
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
const masks = engine.infer(board, revealed)
const candidates = boards.filter((candidate) =>
revealed.every(
(open, i) =>
!open ||
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
),
)
for (const candidate of candidates) {
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
}
const hiddenScrambled = {
...board,
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
}
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
}
})
test('diagonal contact does not connect a color', () => {
assert.equal(
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
false,
)
})

View File

@ -0,0 +1,275 @@
export const LEVELS = {
easy: { size: 10, colors: 4, density: 0.35 },
normal: { size: 25, colors: 6, density: 0.15 },
hard: { size: 60, colors: 10, density: 0 },
} as const
export type Difficulty = keyof typeof LEVELS
export interface Board {
size: number
colors: number
answer: number[]
numbers: number[]
}
export interface Puzzle extends Board {
clues: boolean[]
seed: number
difficulty: Difficulty
}
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
function randomSource(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function shuffle<T>(items: T[], random: () => number) {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[items[i], items[j]] = [items[j], items[i]]
}
return items
}
export function createEngine(size: number, colors: number) {
const count = size * size
const all = (1 << colors) - 1
const indices = Array.from({ length: count }, (_, i) => i)
const around = indices.map((i) => {
const neighbors: number[] = []
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x = (i % size) + dx
const y = Math.floor(i / size) + dy
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
neighbors.push(y * size + x)
}
}
}
return neighbors
})
const distance = (a: number, b: number) =>
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
function grow(random: () => number): Board {
const answer: number[] = Array(count).fill(-1)
const areas: number[] = Array(colors).fill(0)
const seeds: number[] = []
for (let color = 0; color < colors; color++) {
const candidates = shuffle(
indices.filter((i) => answer[i] < 0),
random,
).slice(0, 80)
if (color) {
candidates.sort(
(a, b) =>
Math.min(...seeds.map((s) => distance(b, s))) -
Math.min(...seeds.map((s) => distance(a, s))),
)
}
const seed = candidates[0]
answer[seed] = color
areas[color]++
seeds.push(seed)
}
const frontier: number[][] = Array.from({ length: colors }, () => [])
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
function extend(i: number, color: number) {
for (const j of cross[i]) {
if (answer[j] < 0 && !queued[color][j]) {
frontier[color].push(j)
queued[color][j] = 1
}
}
}
seeds.forEach(extend)
let left = count - colors
while (left) {
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
available.sort((a, b) => areas[a] - areas[b])
if (!available.length) throw new Error('Incomplete region growth')
const color = available[Math.floor(random() * Math.min(2, available.length))]
const list = frontier[color]
const position = Math.floor(random() * list.length)
const i = list[position]
list[position] = list[list.length - 1]
list.pop()
if (answer[i] >= 0) continue
answer[i] = color
areas[color]++
left--
extend(i, color)
}
return {
size,
colors,
answer,
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
}
}
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
function propagate(board: Board, revealed: boolean[], masks: number[]) {
let changed = true
while (changed) {
changed = false
for (const i of indices) {
if (!revealed[i]) continue
const bit = 1 << board.answer[i]
const target = board.numbers[i]
let sure = 0
const optional: number[] = []
for (const j of around[i]) {
if (masks[j] === bit) sure++
else if (masks[j] & bit) optional.push(j)
}
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
if (sure === target || sure + optional.length === target) {
for (const j of optional) {
masks[j] = sure === target ? masks[j] & ~bit : bit
changed = true
}
}
}
}
}
function connectivity(masks: number[]) {
let changed = false
for (let color = 0; color < colors; color++) {
const bit = 1 << color
const source = masks.indexOf(bit)
if (source < 0) continue
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
const order = new Int32Array(count)
const low = new Int32Array(count)
const parent = new Int32Array(count).fill(-1)
const terminals = new Int32Array(count)
const edge = new Uint8Array(count)
const stack = [source]
let time = 1
order[source] = low[source] = terminals[source] = 1
while (stack.length) {
const u = stack[stack.length - 1]
if (edge[u] < cross[u].length) {
const v = cross[u][edge[u]++]
if (!(masks[v] & bit)) continue
if (!order[v]) {
parent[v] = u
order[v] = low[v] = ++time
terminals[v] = masks[v] === bit ? 1 : 0
stack.push(v)
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
} else {
stack.pop()
const p = parent[u]
if (p < 0) continue
low[p] = Math.min(low[p], low[u])
terminals[p] += terminals[u]
// A bridge must be this color if its removal separates two known same-color cells.
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
masks[p] = bit
changed = true
}
}
}
for (const i of indices) {
if (masks[i] & bit && !order[i]) {
masks[i] &= ~bit
changed = true
}
}
}
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
return changed
}
function infer(board: Board, revealed: boolean[]) {
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
do {
propagate(board, revealed, masks)
} while (connectivity(masks))
return masks
}
function solve(board: Board, clues: boolean[]) {
const revealed = clues.slice()
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
while (true) {
propagate(board, revealed, masks)
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
if (!next.length) {
if (!connectivity(masks)) return revealed
continue
}
for (const i of next) {
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
revealed[i] = true
}
}
}
return { around, cross, grow, infer, solve }
}
export function generate(seed: number, difficulty: Difficulty): Puzzle {
const level = LEVELS[difficulty]
const random = randomSource(seed)
const engine = createEngine(level.size, level.colors)
const board = engine.grow(random)
const indices = board.answer.map((_, i) => i)
const clues: boolean[] = indices.map(() => false)
for (let color = 0; color < level.colors; color++) {
const cells = shuffle(
indices.filter((i) => board.answer[i] === color),
random,
)
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
clues[cells[0]] = true
}
let solved = engine.solve(board, clues)
while (solved.some((value) => !value)) {
const cells = shuffle(
indices.filter((i) => !solved[i]),
random,
)
cells.sort(
(a, b) =>
engine.around[b].filter((j) => !solved[j]).length -
engine.around[a].filter((j) => !solved[j]).length,
)
clues[cells[0]] = true
solved = engine.solve(board, clues)
}
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
for (const i of shuffle(
indices.filter((i) => clues[i]),
random,
)) {
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
clues[i] = false
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
}
const target = Math.ceil(indices.length * level.density)
const pools = Array.from({ length: level.colors }, (_, color) =>
shuffle(
indices.filter((i) => board.answer[i] === color && !clues[i]),
random,
),
)
let total = clues.filter(Boolean).length
while (total < target) {
for (const pool of pools) {
if (pool.length && total < target) {
clues[pool.pop()!] = true
total++
}
}
}
return { ...board, clues, seed, difficulty }
}

View File

@ -0,0 +1,9 @@
import { type Difficulty, generate } from './engine'
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
try {
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
} catch {
self.postMessage({ error: true })
}
}

View File

@ -0,0 +1,93 @@
import { defineMessages } from '@modrinth/ui'
export const messages = defineMessages({
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
choose: {
id: 'app.easteregg.color-mine.choose',
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
},
selected: {
id: 'app.easteregg.color-mine.selected',
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
},
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
rules: {
id: 'app.easteregg.color-mine.rules',
defaultMessage:
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
},
loading: {
id: 'app.easteregg.color-mine.loading',
defaultMessage: 'Generating regions and checking the deduction path…',
},
generateError: {
id: 'app.easteregg.color-mine.generateError',
defaultMessage: 'Could not generate this board. Try again.',
},
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
progress: {
id: 'app.easteregg.color-mine.progress',
defaultMessage: '{count} / {total} revealed',
},
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
lost: {
id: 'app.easteregg.color-mine.lost',
defaultMessage:
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
},
review: {
id: 'app.easteregg.color-mine.review',
defaultMessage: 'Unrevealed answers are now shown faded for review.',
},
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
leaveText: {
id: 'app.easteregg.color-mine.leaveText',
defaultMessage: 'Save to continue later, or discard this unfinished board.',
},
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
replaceTitle: {
id: 'app.easteregg.color-mine.replaceTitle',
defaultMessage: 'Start a new board?',
},
replaceText: {
id: 'app.easteregg.color-mine.replaceText',
defaultMessage: 'The current board will be discarded.',
},
savedTitle: {
id: 'app.easteregg.color-mine.savedTitle',
defaultMessage: 'An unfinished board is saved',
},
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
storageError: {
id: 'app.easteregg.color-mine.storageError',
defaultMessage:
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
},
recordError: {
id: 'app.easteregg.color-mine.recordError',
defaultMessage: 'Finished, but the best time could not be saved.',
},
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
board: {
id: 'app.easteregg.color-mine.board',
defaultMessage: '{size} by {size} color deduction board',
},
hidden: {
id: 'app.easteregg.color-mine.hidden',
defaultMessage: 'Row {row}, column {column}, unrevealed',
},
revealed: {
id: 'app.easteregg.color-mine.revealed',
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
},
})

View File

@ -0,0 +1,13 @@
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
.color-mine {
--mine-color-0: var(--color-red);
--mine-color-1: #d7ac37;
--mine-color-2: var(--color-green);
--mine-color-3: var(--color-blue);
--mine-color-4: var(--color-purple);
--mine-color-5: var(--color-orange);
--mine-color-6: #97ba35;
--mine-color-7: #22b6b8;
--mine-color-8: var(--color-pink);
--mine-color-9: #8999ac;
}

View File

@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { generate } from './engine.ts'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage.ts'
const puzzle = generate(123, 'easy')
const saved: SavedGame = {
version: 1,
seed: puzzle.seed,
difficulty: puzzle.difficulty,
revealed: puzzle.clues.slice(),
selected: -1,
elapsed: 4321,
zoom: 1,
left: 12,
top: 34,
}
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
removeItem: (key: string) => {
values.delete(key)
},
}
}
test('save round trip keeps board progress, brush, timer and viewport', () => {
const storage = memoryStorage()
saveGame(storage, saved)
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
validateResume(saved, puzzle)
assert.equal(parseSave(null), null)
})
test('malformed and incompatible saves are rejected without overwriting data', () => {
for (const bad of [
null,
{},
{ ...saved, version: 2 },
{ ...saved, difficulty: '__proto__' },
{ ...saved, revealed: [true] },
{ ...saved, elapsed: -1 },
{ ...saved, selected: 15 },
{ ...saved, zoom: 5 },
{ ...saved, seed: 1.2 },
{ ...saved, top: -1 },
{ ...saved, revealed: saved.revealed.map(() => true) },
]) {
assert.throws(() => parseSave(JSON.stringify(bad)))
}
assert.throws(() => parseSave('{invalid'))
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
assert.throws(() =>
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
)
const storage = memoryStorage()
saveGame(storage, saved)
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
})
test('storage failures are surfaced so the UI can keep the active board', () => {
assert.throws(() =>
saveGame(
{
...memoryStorage(),
setItem: () => {
throw new Error('Quota exceeded')
},
},
saved,
),
)
})
test('best times are independent per difficulty and only improve', () => {
const storage = memoryStorage()
assert.equal(readBest(storage, 'easy'), null)
assert.equal(recordBest(storage, 'easy', 1000), 1000)
assert.equal(recordBest(storage, 'easy', 2000), 1000)
assert.equal(recordBest(storage, 'easy', 500), 500)
assert.equal(recordBest(storage, 'hard', 9000), 9000)
assert.equal(readBest(storage, 'easy'), 500)
assert.throws(() => recordBest(storage, 'easy', -1))
})

View File

@ -0,0 +1,93 @@
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
export const SAVE_KEY = 'starlight.color-mine.save.v1'
const BEST_KEY = 'starlight.color-mine.best.v1'
export interface SavedGame {
version: 1
seed: number
difficulty: Difficulty
revealed: boolean[]
selected: number
elapsed: number
zoom: number
left: number
top: number
}
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export function parseSave(raw: string | null): SavedGame | null {
if (raw === null) return null
const value = JSON.parse(raw) as Partial<SavedGame> | null
if (
!value ||
value.version !== 1 ||
!value.difficulty ||
!Object.keys(LEVELS).includes(value.difficulty)
) {
throw new Error('Invalid saved game')
}
const level = LEVELS[value.difficulty]
if (
!finite(value.seed) ||
!Number.isInteger(value.seed) ||
value.seed < 0 ||
value.seed > 0xffffffff ||
!Array.isArray(value.revealed) ||
value.revealed.length !== level.size ** 2 ||
value.revealed.some((v) => typeof v !== 'boolean') ||
value.revealed.every(Boolean) ||
!finite(value.selected) ||
!Number.isInteger(value.selected) ||
value.selected < -1 ||
value.selected >= level.colors ||
!finite(value.elapsed) ||
value.elapsed < 0 ||
!finite(value.zoom) ||
value.zoom < 0.7 ||
value.zoom > 1.6 ||
!finite(value.left) ||
value.left < 0 ||
!finite(value.top) ||
value.top < 0
)
throw new Error('Invalid saved game')
return value as SavedGame
}
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
if (
saved.seed !== puzzle.seed ||
saved.difficulty !== puzzle.difficulty ||
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
(saved.selected >= 0 &&
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
) {
throw new Error('Saved game does not match puzzle')
}
}
export function saveGame(storage: StoragePort, saved: SavedGame) {
const raw = JSON.stringify(saved)
parseSave(raw)
storage.setItem(SAVE_KEY, raw)
}
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
const raw = storage.getItem(BEST_KEY)
if (!raw) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object') return null
const time = (value as Record<string, unknown>)[difficulty]
return finite(time) && time >= 0 ? time : null
}
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
const best = Object.fromEntries(
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
)
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
storage.setItem(BEST_KEY, JSON.stringify(best))
return best[difficulty]
}

View File

@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
color: var(--color-contrast); color: var(--color-contrast);
font-size: 4.5rem; font-size: 4.5rem;
font-weight: 800; font-weight: 800;
line-height: 1; // Keep descenders inside the line box clipped by the wordmark reveal animation.
line-height: normal;
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;

View File

@ -1,20 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
ChevronDownIcon,
ExternalIcon,
ScaleIcon,
UsersIcon,
} from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui' import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue' 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 EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue' import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config' import { AxolotlBrandConfig } from '@/config'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import { contributors, teamMembers, type TeamMember } from '@/data/about'
import AboutScene from '../AboutScene.vue' import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences' import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
@ -24,7 +19,7 @@ const version = await getVersion()
const experienceHost = ref<HTMLElement>() const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>() const activeMemberExperience = shallowRef<AboutMemberExperience>()
const pressingMemberName = ref<string>() const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 } let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding') const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
@ -78,6 +73,24 @@ function closeMemberExperience() {
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null) const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null) const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
const ColorMineModal = defineAsyncComponent(
() => import('@/components/ui/easteregg/color-mine/ColorMineModal.vue'),
)
const colorMineModal = ref<{ show: () => void }>()
const colorMineMounted = ref(false)
const colorMinePending = ref(false)
function openColorMine() {
colorMineMounted.value = true
if (colorMineModal.value) colorMineModal.value.show()
else colorMinePending.value = true
}
function colorMineReady() {
if (!colorMinePending.value) return
colorMinePending.value = false
colorMineModal.value?.show()
}
let typedBuffer = '' let typedBuffer = ''
const secretCodes = ['starlight'] const secretCodes = ['starlight']
@ -166,7 +179,8 @@ const messages = defineMessages({
}, },
attribution: { attribution: {
id: 'app.settings.about.attribution', id: 'app.settings.about.attribution',
defaultMessage: 'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.', defaultMessage:
'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
}, },
notAffiliated: { notAffiliated: {
id: 'app.settings.about.not-affiliated', id: 'app.settings.about.not-affiliated',
@ -194,7 +208,6 @@ const messages = defineMessages({
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}', defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
}, },
}) })
</script> </script>
<template> <template>
@ -237,7 +250,6 @@ const messages = defineMessages({
</p> </p>
</section> </section>
<section> <section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast"> <h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" /> <ScaleIcon class="size-5 text-secondary" />
@ -288,9 +300,31 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }} {{ formatMessage(messages.developmentTeam) }}
</h3> </h3>
<div class="grid gap-3 sm:grid-cols-2"> <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 <a
v-for="member in teamMembers" :href="member.url"
:key="member.name" 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" :href="member.url ?? undefined"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -315,6 +349,7 @@ const messages = defineMessages({
</span> </span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" /> <ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a> </a>
</template>
</div> </div>
</section> </section>
<details class="group pt-4 about-settings-details"> <details class="group pt-4 about-settings-details">
@ -360,6 +395,7 @@ const messages = defineMessages({
</div> </div>
<EasterEggGameModal ref="gameModal" /> <EasterEggGameModal ref="gameModal" />
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" /> <EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template> </template>

View File

@ -1,4 +1,109 @@
{ {
"app.easteregg.color-mine.title": {
"message": "Starlight Mine: Chromatic Realms"
},
"app.easteregg.color-mine.easy": {
"message": "Easy"
},
"app.easteregg.color-mine.normal": {
"message": "Normal"
},
"app.easteregg.color-mine.hard": {
"message": "Hard"
},
"app.easteregg.color-mine.newGame": {
"message": "New board"
},
"app.easteregg.color-mine.close": {
"message": "Close"
},
"app.easteregg.color-mine.choose": {
"message": "Click a revealed tile to sample its color, then paint a gray tile."
},
"app.easteregg.color-mine.selected": {
"message": "Color {color} selected · Click a colored tile to change your brush."
},
"app.easteregg.color-mine.rulesTitle": {
"message": "How to play"
},
"app.easteregg.color-mine.rules": {
"message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing."
},
"app.easteregg.color-mine.loading": {
"message": "Generating regions and checking the deduction path…"
},
"app.easteregg.color-mine.generateError": {
"message": "Could not generate this board. Try again."
},
"app.easteregg.color-mine.retry": {
"message": "Try again"
},
"app.easteregg.color-mine.progress": {
"message": "{count} / {total} revealed"
},
"app.easteregg.color-mine.best": {
"message": "Best · {time}"
},
"app.easteregg.color-mine.won": {
"message": "All colors restored!"
},
"app.easteregg.color-mine.lost": {
"message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over."
},
"app.easteregg.color-mine.review": {
"message": "Unrevealed answers are now shown faded for review."
},
"app.easteregg.color-mine.leaveTitle": {
"message": "Leave this board?"
},
"app.easteregg.color-mine.leaveText": {
"message": "Save to continue later, or discard this unfinished board."
},
"app.easteregg.color-mine.saveLeave": {
"message": "Save and leave"
},
"app.easteregg.color-mine.discard": {
"message": "Discard"
},
"app.easteregg.color-mine.continue": {
"message": "Keep playing"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "Start a new board?"
},
"app.easteregg.color-mine.replaceText": {
"message": "The current board will be discarded."
},
"app.easteregg.color-mine.savedTitle": {
"message": "An unfinished board is saved"
},
"app.easteregg.color-mine.resume": {
"message": "Resume saved game"
},
"app.easteregg.color-mine.storageError": {
"message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it."
},
"app.easteregg.color-mine.recordError": {
"message": "Finished, but the best time could not be saved."
},
"app.easteregg.color-mine.zoomIn": {
"message": "Zoom in"
},
"app.easteregg.color-mine.zoomOut": {
"message": "Zoom out"
},
"app.easteregg.color-mine.map": {
"message": "Board overview. Click to navigate."
},
"app.easteregg.color-mine.board": {
"message": "{size} by {size} color deduction board"
},
"app.easteregg.color-mine.hidden": {
"message": "Row {row}, column {column}, unrevealed"
},
"app.easteregg.color-mine.revealed": {
"message": "Row {row}, column {column}, color {color}, {number} same-color neighbors"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "Signed in as" "message": "Signed in as"
}, },

View File

@ -1,4 +1,109 @@
{ {
"app.easteregg.color-mine.title": {
"message": "星光矿井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "简单"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困难"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "关闭"
},
"app.easteregg.color-mine.choose": {
"message": "先点击已揭示的色块取色,再给灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 点击其他色块切换画笔。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法说明"
},
"app.easteregg.color-mine.rules": {
"message": "数字表示周围八格中同色格的数量,不含自身。每种颜色必须组成一个上下左右连通的区域,斜角不算连接。点击已揭示的格子取色,染对会显示新数字,染错则本局结束。每局均经过逐步推理验证,无需猜测。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域并校验推理路径…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盘生成失败,请重试。"
},
"app.easteregg.color-mine.retry": {
"message": "重试"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已还原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染错了:选择了 {selected},正确颜色为 {correct}。本局结束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色显示,可查看棋盘复盘。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要离开当前棋盘吗?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以保存,下次从这里继续;也可以放弃本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "保存并离开"
},
"app.easteregg.color-mine.discard": {
"message": "放弃本局"
},
"app.easteregg.color-mine.continue": {
"message": "继续这局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "开始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "当前这局的进度将被放弃。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盘"
},
"app.easteregg.color-mine.resume": {
"message": "继续上次的棋盘"
},
"app.easteregg.color-mine.storageError": {
"message": "无法读取或保存存档,当前棋盘已保留。可重试,或选择放弃存档。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成绩未能保存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "缩小"
},
"app.easteregg.color-mine.map": {
"message": "棋盘总览,点击定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盘"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
},
"app.settings.developer.announcement-preview": { "message": "公告样式预览" }, "app.settings.developer.announcement-preview": { "message": "公告样式预览" },
"app.settings.developer.announcement-preview-description": { "app.settings.developer.announcement-preview-description": {
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。" "message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"

View File

@ -1,4 +1,109 @@
{ {
"app.easteregg.color-mine.title": {
"message": "星光礦井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "簡單"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困難"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "關閉"
},
"app.easteregg.color-mine.choose": {
"message": "先點擊已揭示的色塊取色,再為灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 點擊其他色塊切換畫筆。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法說明"
},
"app.easteregg.color-mine.rules": {
"message": "數字表示周圍八格中同色格的數量,不含自身。每種顏色必須組成一個上下左右連通的區域,斜角不算連接。點擊已揭示的格子取色,染對會顯示新數字,染錯則本局結束。每局均經過逐步推理驗證,無需猜測。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域並校驗推理路徑…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盤生成失敗,請重試。"
},
"app.easteregg.color-mine.retry": {
"message": "重試"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已還原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染錯了:選擇了 {selected},正確顏色為 {correct}。本局結束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色顯示,可查看棋盤複盤。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要離開目前棋盤嗎?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以儲存,下次從這裡繼續;也可以放棄本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "儲存並離開"
},
"app.easteregg.color-mine.discard": {
"message": "放棄本局"
},
"app.easteregg.color-mine.continue": {
"message": "繼續這局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "開始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "目前這局的進度將被放棄。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盤"
},
"app.easteregg.color-mine.resume": {
"message": "繼續上次的棋盤"
},
"app.easteregg.color-mine.storageError": {
"message": "無法讀取或儲存存檔,目前棋盤已保留。可重試,或選擇放棄存檔。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成績未能儲存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "縮小"
},
"app.easteregg.color-mine.map": {
"message": "棋盤總覽,點擊定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盤"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,顏色 {color},周圍同色 {number} 格"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "登入身分:" "message": "登入身分:"
}, },