update: game core flow pages

This commit is contained in:
Vick Scarlet
2026-08-16 20:06:16 +08:00
committed by 神戸小鳥
parent 25bd9ce2b7
commit 0c0fac5bfd
34 changed files with 890 additions and 768 deletions
+2
View File
@@ -6,6 +6,8 @@
<meta name="keywords" content="人生重开模拟器 liferestart life restart remake 人生重来"/> <meta name="keywords" content="人生重开模拟器 liferestart life restart remake 人生重来"/>
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<title>人生重开模拟器</title> <title>人生重开模拟器</title>
</head> </head>
<body> <body>
-1
View File
@@ -24,7 +24,6 @@
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"eslint-config-react": "^1.1.7", "eslint-config-react": "^1.1.7",
"jotai-devtools": "^0.14.0",
"vite": "^8.1.3", "vite": "^8.1.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
+24 -1
View File
@@ -1,6 +1,6 @@
.screen { .screen {
min-width: 32rem; min-width: 32rem;
max-width: calc(100dvw - 2rem); max-width: min(calc(100dvw - 2rem), 48rem);
position: relative; position: relative;
padding: 0; padding: 0;
margin: 0; margin: 0;
@@ -9,4 +9,27 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
> * { width: 100%; } > * { width: 100%; }
> .section {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
padding: 0;
box-sizing: border-box;
.title {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
font-weight: bold;
width: 100%;
height: 2rem;
color: var(--base-text-color);
border: 0.0625rem solid var(--base-text-color);
border-bottom: 0.1875rem solid var(--color-primary);
box-sizing: border-box;
user-select: none;
}
}
} }
+6 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { useInit } from '@/hooks/storage' import { useInit, useSaver } from '@/hooks/storage'
import { useStep, Step } from '@remake/hooks' import { useStep, Step } from '@remake/hooks'
import Home from '@/containers/Home' import Home from '@/containers/Home'
import Pick from '@/containers/TalentPick' import Pick from '@/containers/TalentPick'
@@ -11,10 +11,14 @@ import './Game.css'
export function Game() { export function Game() {
const [inited, init] = useInit() const [inited, init] = useInit()
const saver = useSaver()
const step = useStep() const step = useStep()
useEffect(() => { useEffect(() => {
if (!inited) init() if (!inited) init()
}, [inited]) }, [inited])
useEffect(() => {
saver()
}, [saver])
if (!inited) return <Loading /> if (!inited) return <Loading />
switch (step) { switch (step) {
case Step.Idle: case Step.Idle:
+5 -7
View File
@@ -4,28 +4,26 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 1rem; padding: 0 1rem;
line-height: 2rem; height: 2rem;
font-size: 1rem;
gap: 1rem; gap: 1rem;
color: var(--color); color: var(--color);
background: var(--background, var(--base-background-color)); background: var(--background, var(--base-background-color));
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border: 0.0625rem solid var(--grade-color); border: 0.0625rem solid var(--grade-color);
box-sizing: border-box;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
&.talent-grade-0 { --grade-color: var(--color-grade-0); }
&.talent-grade-1 { --grade-color: var(--color-grade-1); }
&.talent-grade-2 { --grade-color: var(--color-grade-2); }
&.talent-grade-3 { --grade-color: var(--color-grade-3); }
&.selected { &.selected {
--color: var(--base-background-color); --color: var(--base-background-color);
--background: var(--grade-color); --background: var(--grade-color);
&:hover { --background: oklch(from var(--grade-color) calc(l + 0.1) c h); } &:hover { --background: oklch(from var(--grade-color) calc(l + 0.1) c h); }
} }
&:hover { --background: oklch(from var(--grade-color) calc(l + 0.5) c h); } &:hover { --background: oklch(from var(--grade-color) calc(l + 0.5) c h); }
.talent-name { font-weight: bold; }
.talent-description { .talent-description {
color: oklch(from var(--color) calc(l + 0.1) c h); color: oklch(from var(--color) calc(l + 0.1) c h);
font-size: 0.6rem; font-size: 0.85rem;
align-self: flex-end;
user-select: none; user-select: none;
&::before { content: '['; margin-right: 0.125rem; } &::before { content: '['; margin-right: 0.125rem; }
&::after { content: ']'; margin-left: 0.125rem; } &::after { content: ']'; margin-left: 0.125rem; }
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Talent } from '@remake/data/talent' import type { Talent } from '@remake/data/talent'
import talents from '@remake/data/talent' import talents from '@remake/data/talent'
import cx from 'classnames'
import './Talent.css' import './Talent.css'
export interface TalentProps { export interface TalentProps {
@@ -12,7 +11,7 @@ export function TalentComponent({ id, selected }: TalentProps) {
if (!talent) return null if (!talent) return null
const grade = talent?.grade ?? 0 const grade = talent?.grade ?? 0
return ( return (
<div className={cx('talent', `talent-grade-${grade}`, { selected })}> <div className={`talent grade-${grade} ${selected ? 'selected' : ''}`}>
<span className="talent-name">{talent.name}</span> <span className="talent-name">{talent.name}</span>
<span className="talent-description">{talent.description}</span> <span className="talent-description">{talent.description}</span>
</div> </div>
+11 -32
View File
@@ -1,24 +1,5 @@
.theme-toggle-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 50%;
position: fixed;
top: 0.5rem;
right: 0.5rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.3s;
--hover-color: rgba(0, 0, 0, 0.05);
&:hover { background-color: var(--hover-color);}
}
.theme-svg { .theme-svg {
--svg-color: oklch(from var(--text-color, var(--base-text-color)) calc(l + 0.2) c h);
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: center; transform-origin: center;
.sun-center, .sun-beams, .mask-cutter-group { .sun-center, .sun-beams, .mask-cutter-group {
@@ -26,21 +7,19 @@
transform-origin: center; transform-origin: center;
} }
.mask-cutter-group { transform: translate(0.75rem, -0.75rem); } .mask-cutter-group { transform: translate(0.75rem, -0.75rem); }
.sun-center { fill: oklch(from var(--base-text-color) calc(l + 0.2) c h); } .sun-center {
.sun-beams { stroke: oklch(from var(--base-text-color) calc(l + 0.2) c h); } transition: fill 0.2s ease-in-out;
fill: var(--svg-color);
}
.sun-beams {
transition: stroke 0.2s ease-in-out;
stroke: var(--svg-color);
}
} }
html[data-theme='dark'] { html[data-theme='dark'] {
.theme-toggle-btn { --hover-color: rgba(255, 255, 255, 0.1); }
.mask-cutter-group { transform: translate(0, 0); } .mask-cutter-group { transform: translate(0, 0); }
.theme-svg { transform: rotate(375deg); } .theme-svg { transform: rotate(375deg); }
.sun-center { .sun-center { transform: scale(1.5); }
transform: scale(1.5); .sun-beams { opacity: 0; transform: scale(0.6); }
fill: #fbbf24;
}
.sun-beams {
opacity: 0;
transform: scale(0.6);
stroke: #fbbf24;
}
} }
+21 -3
View File
@@ -1,13 +1,31 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import './ThemeToggle.css' import './ThemeToggle.css'
export const updateThemeColor = () => {
let metas = document.querySelectorAll('meta[name="theme-color"]')
if (!metas.length) return
const element = document.body
const end = Date.now() + 300
const update = () => {
const { backgroundColor } = getComputedStyle(element)
metas.forEach(meta => meta.setAttribute('content', backgroundColor))
if (Date.now() < end) requestAnimationFrame(update)
}
requestAnimationFrame(update)
}
export function ThemeToggle() { export function ThemeToggle() {
const [isDark, setIsDark] = useState( const [isDark, setIsDark] = useState(() => {
() => matchMedia('(prefers-color-scheme: dark)').matches, const storedTheme = localStorage.getItem('theme')
) if (storedTheme === 'dark') return true
if (storedTheme === 'light') return false
return matchMedia('(prefers-color-scheme: dark)').matches
})
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = isDark ? 'dark' : 'light' document.documentElement.dataset.theme = isDark ? 'dark' : 'light'
updateThemeColor()
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}, [isDark]) }, [isDark])
return ( return (
+73 -48
View File
@@ -1,59 +1,84 @@
import type { Config } from '@remake/hooks' import type { Config } from '@remake/hooks'
import type { TalentGrade } from '@remake/data/talent'
function gadm(grade: TalentGrade, value: number) {
return new Map([[grade, { mode: 'multiply', value }]])
}
export const AdditonMap = {
times: [
{ value: 0, additions: new Map() },
{ value: 10, additions: gadm(2, 2) },
{ value: 30, additions: gadm(2, 3) },
{ value: 50, additions: gadm(2, 4) },
{ value: 70, additions: gadm(2, 5) },
{ value: 100, additions: gadm(2, 6) },
],
achievements: [
{ value: 0, additions: new Map() },
{ value: 10, additions: gadm(3, 2) },
{ value: 30, additions: gadm(3, 3) },
{ value: 50, additions: gadm(3, 4) },
{ value: 70, additions: gadm(3, 5) },
{ value: 100, additions: gadm(3, 6) },
],
}
export const AutoInterval = 500
export const ModeLimit = 10
export const PickLimit = 3
export const BasePoints = 20 export const BasePoints = 20
export const AllocLimit = 10
export const DefaultSpirit = 5
export const PullCount = 10
export const PullRateBase: Config['pull']['rate']['base'] = new Map([
[0, 889],
[1, 100],
[2, 10],
[3, 1],
])
export const JudgeMap = {
age: [0, 1, 10, 18, 40, 60, 70, 80, 90, 95, 100, 500],
summary: [0, 41, 50, 60, 80, 100, 110, 120],
times: [0, 10, 30, 50, 70, 100],
achievements: [0, 10, 30, 50, 70, 100],
charm: [0, 1, 2, 4, 7, 9, 11],
money: [0, 1, 2, 4, 7, 9, 11],
spirit: [0, 1, 2, 4, 7, 9, 11],
intelligence: [0, 1, 2, 4, 7, 9, 11, 21, 131, 501],
strength: [0, 1, 2, 4, 7, 9, 11, 21, 101, 401, 1001, 2001],
talent: [0, 0.3, 0.6, 0.9],
event: [0, 0.2, 0.4, 0.6],
}
export type Judges = keyof typeof JudgeMap
export function judge(key: Judges, value: number) {
const arr = JudgeMap[key]
for (let i = arr.length - 1; i >= 0; i--) {
if (value >= arr[i]!) return i
}
return 0
}
export const JudgeGradeMap = {
age: [0, 5, 7, 9],
summary: [0, 4, 5, 6],
times: [0, 1, 3, 5],
achievements: [0, 1, 3, 5],
charm: [0, 4, 5, 6],
money: [0, 4, 5, 6],
spirit: [0, 4, 5, 6],
intelligence: [0, 4, 5, 6],
strength: [0, 4, 5, 6],
talent: [0, 1, 2, 3],
event: [0, 1, 2, 3],
}
export function judgeGrade(key: Judges, level: number) {
const arr = JudgeGradeMap[key]
for (let i = arr.length - 1; i >= 0; i--) {
if (level >= arr[i]!) return i
}
return 0
}
export const PullRateAdditions: Config['pull']['rate']['additions'] = {
times: value => {
const level = judge('times', value)
return new Map([[2, { mode: 'multiply', value: level + 1 } as const]])
},
achievements: value => {
const level = judge('achievements', value.size)
return new Map([[3, { mode: 'multiply', value: level + 1 } as const]])
},
}
export const config: Config = { export const config: Config = {
mode: 10, mode: ModeLimit,
pick: 3, pick: PickLimit,
points: BasePoints, points: BasePoints,
allocate: 10, allocate: AllocLimit,
spirit: 5, spirit: DefaultSpirit,
pull: { pull: {
count: 10, count: PullCount,
rate: { rate: {
base: new Map([ base: PullRateBase,
[0, 889], additions: PullRateAdditions,
[1, 100],
[2, 10],
[3, 1],
]),
additions: {
times: value => {
for (const item of AdditonMap.times.reverse())
if (value >= item.value) return item.additions
return new Map()
},
achievements: value => {
const size = value.size
for (const item of AdditonMap.achievements.reverse())
if (size >= item.value) return item.additions
return new Map()
},
},
}, },
}, },
} }
+13 -9
View File
@@ -8,21 +8,23 @@
.allocation-input { .allocation-input {
display: flex; display: flex;
gap: 0; gap: 0;
height: 2rem;
border: 0.0625rem solid var(--base-text-color); border: 0.0625rem solid var(--base-text-color);
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
button { button {
flex: 1 0;
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 2rem; height: 100%;
height: 2rem;
border: none; border: none;
text-align: center; text-align: center;
} }
input { input {
flex: 1; flex: 1 0;
height: 2rem; height: 100%;
width: 2rem; width: 2rem;
font-weight: bold;
} }
} }
.points-detail { .points-detail {
@@ -42,7 +44,8 @@
li { li {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
line-height: 2rem; align-items: center;
height: 2rem;
padding: 0 0.5rem; padding: 0 0.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -72,9 +75,11 @@
height: 2rem; height: 2rem;
border: 0.0625rem solid var(--base-text-color); border: 0.0625rem solid var(--base-text-color);
border-bottom: none; border-bottom: none;
text-align: center; display: flex;
line-height: 2rem; justify-content: center;
align-items: center;
box-sizing: border-box; box-sizing: border-box;
font-weight: bold;
} }
} }
> li.left { > li.left {
@@ -87,10 +92,9 @@
> button { > button {
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
line-height: 2rem; height: 2rem;
width: 6rem; width: 6rem;
padding: 0; padding: 0;
box-sizing: border-box;
} }
} }
} }
+6 -3
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks' import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks'
import { usePicked, useReplaced } from '@remake/hooks' import { usePicked, useReplaced, useStart } from '@remake/hooks'
import type { AdditionalPoint } from '@remake/hooks' import type { AdditionalPoint } from '@remake/hooks'
import { properties } from '@/display' import { properties } from '@/display'
import { keys } from '@remake/vitex' import { keys } from '@remake/vitex'
@@ -59,9 +59,12 @@ export function Alloc() {
const left = useLeftPoints() const left = useLeftPoints()
const [allocation, allocator] = useAllocator() const [allocation, allocator] = useAllocator()
const random = usePointRandomizer() const random = usePointRandomizer()
const start = useStart()
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false)
const handleNext = () => { const handleNext = () => {
// navigate('/life') const achievements = start()
// TODO: Show achievements
console.log('Achievements', achievements)
} }
return ( return (
<div className="screen point-allocation"> <div className="screen point-allocation">
@@ -97,7 +100,7 @@ export function Alloc() {
<button className="info" onClick={() => random()}> <button className="info" onClick={() => random()}>
</button> </button>
<button className="primary" onClick={() => {}}> <button className="primary" onClick={handleNext}>
</button> </button>
</div> </div>
+13 -1
View File
@@ -8,10 +8,22 @@
h1 { h1 {
font-size: 4rem; font-size: 4rem;
margin: 0; margin: 0;
font-weight: bold;
} }
h2 { h2 {
font-size: 2rem; font-size: 2rem;
margin: 0; margin: 0;
} }
} }
} .theme-toggle-btn {
display: flex;
width: 2.5rem;
height: 2.5rem;
padding: 0.25rem;
cursor: pointer;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.3s;
}
}
+2
View File
@@ -1,4 +1,5 @@
import { useRemake } from '@remake/hooks' import { useRemake } from '@remake/hooks'
import { ThemeToggle } from '@/components/ThemeToggle'
import './Home.css' import './Home.css'
export default function Home() { export default function Home() {
@@ -12,6 +13,7 @@ export default function Home() {
<button className="primary" onClick={remake}> <button className="primary" onClick={remake}>
</button> </button>
<ThemeToggle />
</div> </div>
) )
} }
+114
View File
@@ -0,0 +1,114 @@
.screen.play {
height: calc(100dvh - 2rem);
gap: 1rem;
> ul.properties {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
font-weight: bold;
> li {
display: flex;
flex-direction: column;
flex: 1 0;
justify-content: space-between;
text-align: center;
min-width: 6rem;
user-select: none;
span {
display: flex;
justify-content: center;
align-items: center;
border: 0.0625rem solid var(--base-text-color);
box-sizing: border-box;
height: 2rem;
&:first-child {
border-bottom: none;
}
}
.value {
color: var(--color-primary);
font-size: 1.25rem;
font-weight: bold;
}
&.charm { order: 1 }
&.intelligence { order: 2 }
&.strength { order: 3 }
&.money { order: 4 }
&.spirit { order: 5 }
}
}
> ul.logs {
display: flex;
flex-direction: column;
flex: 1 0;
gap: 0.5rem;
padding: 0 0.5rem;
min-width: 32rem;
max-width: 100%;
height: 100%;
border: 0.0625rem solid var(--base-text-color);
box-sizing: border-box;
> li.log {
display: flex;
flex-direction: row;
line-height: 2rem;
gap: 0.5rem;
width: 100%;
user-select: none;
cursor: pointer;
> .age {
flex-shrink: 0;
width: 3rem;
height: 2rem;
text-align: right;
color: var(--color-primary);
font-weight: bold;
/* border-bottom: 0.0625rem solid var(--base-text-color); */
box-sizing: border-box;
}
> .content {
position: relative;
&::before {
content: '';
position: absolute;
left: -0.5rem;
bottom: 0;
height: calc(100% - 2rem);
width: calc(100% + 0.5rem);
border: 0.0625rem solid var(--base-text-color);
border-top: none;
border-right: none;
box-sizing: border-box;
pointer-events: none;
}
.tag, .name {
font-weight: bold;
}
}
}
>li:last-child {
margin-bottom: 2rem;
}
}
ul.log-inner {
> li {
color: var(--grade-color);
display: flex;
flex-direction: row;
gap: 0.5rem;
}
}
.controls {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
> button {
flex: 1 0;
min-width: 6rem;
font-size: 1.25rem;
height: 2.5rem;
}
}
}
+150 -2
View File
@@ -1,3 +1,151 @@
export default function Life() { import { useState, useRef } from 'react'
return <></> import { useLayoutEffect, useEffect } from 'react'
import { useNext, useGotoSummary } from '@remake/hooks'
import type { Log } from '@remake/hooks'
import { properties } from '@/display'
import { keys } from '@remake/vitex'
import { achievements, events, talents } from '@remake/data'
import { AutoInterval } from '@/config'
import './Play.css'
function LogTalent({ id }: { id: number }) {
const { name, description, grade } = talents.get(id)!
return (
<li className={`grade-${grade}`}>
<span className="tag font-mono">[]</span>
<span className="name">{name}</span>
<span className="description">{description}</span>
</li>
)
} }
function LogTalents({ items }: { items: number[] }) {
if (items.length === 0) return null
const els = items.map(id => <LogTalent key={id} id={id} />)
return <ul className="log-inner log-talents">{els}</ul>
}
interface LogEventProps {
id: number
post: boolean
}
function LogEvent({ id, post }: LogEventProps) {
const { event, postEvent, grade } = events.get(id)!
return (
<>
<li className={`grade-${grade}`}>{event}</li>
{post && postEvent && (
<li className={`grade-${grade}`}>{postEvent}</li>
)}
</>
)
}
function LogEvents({ items }: { items: number[] }) {
const last = items.length - 1
const els = items.map((id, i) => (
<LogEvent key={id} id={id} post={i == last} />
))
return <ul className="log-inner log-events">{els}</ul>
}
function LogAchievement({ id }: { id: number }) {
const { name, description, grade } = achievements.get(id)!
return (
<li className={`grade-${grade}`}>
<span className="tag font-mono">[]</span>
<span className="name">{name}</span>
<span className="description">{description}</span>
</li>
)
}
function LogAchievements({ items }: { items: number[] }) {
if (items.length === 0) return null
const els = items.map(id => <LogAchievement key={id} id={id} />)
return <ul className="log-inner log-achievements">{els}</ul>
}
interface LogProps {
log: Log
}
function Log({ log }: LogProps) {
return (
<li className="log">
<span className="age font-mono">{log.age}</span>
<div className="content">
<LogTalents items={log.talents} />
<LogEvents items={log.events} />
<LogAchievements items={log.achievements} />
</div>
</li>
)
}
export function Play() {
const [{ state, logs, ended }, next] = useNext()
const [auto, setAuto] = useState(false)
const logRef = useRef<HTMLUListElement>(null)
const autoRef = useRef(0)
const gotoSummary = useGotoSummary()
const handleNext = () => {
if (ended) return
const achievements = next()
// TODO: Show achievements
console.debug('Achievements:', achievements)
}
const handleGotoSummary = () => {
if (!ended) return
const achievements = gotoSummary()
// TODO: Show achievements
console.debug('Achievements:', achievements)
}
useLayoutEffect(() => {
requestAnimationFrame(() => {
if (!logRef.current) return
logRef.current.scrollTop = logRef.current.scrollHeight
})
}, [logs])
useEffect(() => {
if (!auto) window.clearInterval(autoRef.current)
else autoRef.current = window.setInterval(handleNext, AutoInterval)
return () => window.clearInterval(autoRef.current)
}, [auto, handleNext])
return (
<div className="screen play">
<ul className="properties">
{keys(state.props.current, ['age']).map(key => (
<li className={key} key={key}>
<span>{properties[key]}</span>
<span className="font-mono">
{state.props.current[key]}
</span>
</li>
))}
</ul>
<ul
className="logs hide-scrollbar"
onClick={handleNext}
ref={logRef}
>
{logs.map((log, index) => (
<Log key={index} log={log} />
))}
</ul>
<div className="controls">
{!ended && (
<button className="primary" onClick={() => setAuto(!auto)}>
{auto ? '手动' : '自动'}
</button>
)}
{ended && (
<button className="primary" onClick={handleGotoSummary}>
</button>
)}
</div>
</div>
)
}
export default Play
+43
View File
@@ -0,0 +1,43 @@
.screen.summary {
gap: 1rem;
ul.judge-list {
display: flex;
flex-direction: row;
gap: 0.125rem;
width: 100%;
> li {
display: flex;
flex-direction: column;
flex: 1 0;
justify-content: space-between;
text-align: center;
min-width: 4rem;
user-select: none;
span {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
font-weight: bold;
border: 0.0625rem solid var(--grade-color);
box-sizing: border-box;
height: 2rem;
color: var(--grade-color);
&:not(:first-child) {
border-top: none;
}
}
.level {
font-size: 0.95rem;
background-color: var(--grade-color);
color: var(--base-background-color);
}
}
}
ul.talent-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
}
+58 -4
View File
@@ -1,10 +1,64 @@
import { useState } from 'react'
import { usePicked, useEnd } from '@remake/hooks'
import { useJudge } from '@/hooks/judge'
import type { JudgeKeys, Judge } from '@/hooks/judge'
import { properties, judgeDisplay } from '@/display'
import TalentComponent from '@/components/Talent'
import './Summary.css'
interface JudgeItemProps {
prop: JudgeKeys
judge: Judge
}
function JudgeItem({ prop, judge }: JudgeItemProps) {
const { value, grade, level } = judge
return (
<li className={`${prop} grade-${grade}`}>
<span className="property">{properties[prop]}</span>
<span className="value font-mono">{value}</span>
<span className="level">{judgeDisplay(prop, level)}</span>
</li>
)
}
export default function Summary() { export default function Summary() {
const picked = usePicked()
const judges = useJudge()
const end = useEnd()
const [locked, setLocked] = useState<number | null>(null)
const handleSelect = (id: number) => {
if (locked === id) setLocked(null)
else setLocked(id)
}
const handleEnd = () => {
const achievements = end()
// TODO: Show achievements
console.log('Achievements', achievements)
}
return ( return (
<div className="screen summary"> <div className="screen summary">
<h2></h2> <ul className="judge-list">
<p>: {}</p> {judges.map(([key, { value, grade, level }]) => (
{/* 展现解锁的成就等 */} <JudgeItem
<button onClick={() => {}}></button> key={key}
prop={key}
judge={{ value, grade, level }}
/>
))}
</ul>
<div className="section">
<div className="title"></div>
<ul className="talent-list">
{Array.from(picked, id => (
<li key={id} onClick={() => handleSelect(id)}>
<TalentComponent id={id} selected={locked === id} />
</li>
))}
</ul>
</div>
<button className="primary" onClick={handleEnd}>
</button>
</div> </div>
) )
} }
+25
View File
@@ -3,4 +3,29 @@ export const properties = {
intelligence: '智力', intelligence: '智力',
strength: '体质', strength: '体质',
money: '家境', money: '家境',
spirit: '快乐',
age: '享年',
summary: '总评',
}
const s = (str: string) => str.split('|')
const p = (prefix: string, arr: string[]) => arr.map(v => prefix + v)
const jbase = '地狱|折磨|不佳|普通|优秀|罕见|逆天|传说'
export const judges = {
summary: s(jbase),
charm: s(jbase),
money: s(jbase),
spirit: s('地狱|折磨|不幸|普通|幸福|极乐|天命'),
intelligence: s(jbase + '|识海|元神|仙魂'),
strength: s(jbase + '|凝气|筑基|金丹|元婴|仙体'),
age: s('胎死腹中|早夭|少年|盛年|中年|花甲|古稀|杖朝|南山|不老|修仙|仙寿'),
}
export const rates = {
times: p('抽到紫色概率', s('不变|翻倍|三倍|四倍|五倍|六倍')),
achievement: p('抽到橙色概率', s('不变|翻倍|三倍|四倍|五倍|六倍')),
}
export function judgeDisplay(key: keyof typeof judges, level: number) {
return judges[key][level]
} }
+30
View File
@@ -0,0 +1,30 @@
import { useMemo } from 'react'
import { useSummary, useGameState } from '@remake/hooks'
import type { Properties } from '@remake/hooks'
import { judge, judgeGrade } from '@/config'
import { keys } from '@remake/vitex'
export interface Judge {
value: number
level: number
grade: number
}
export type JudgePropKeys = keyof Properties
export type JudgeKeys = 'summary' | JudgePropKeys
export type Judges = [JudgeKeys, Judge][]
export const useJudge = () => {
const summary = useSummary()
const state = useGameState()
if (!state) throw new Error('Game state is not set')
return useMemo(() => {
const props = { ...state.props.highest, summary }
const judges: Judges = []
for (const key of keys(props)) {
const value = props[key]
const level = judge(key, value)
const grade = judgeGrade(key, level)
judges.push([key, { value, level, grade }])
}
return judges
}, [state.props.highest, summary])
}
+21 -21
View File
@@ -1,6 +1,6 @@
import { useCallback, useTransition } from 'react' import { useCallback } from 'react'
import { atom, useAtom } from 'jotai' import { atom, useAtom } from 'jotai'
import { useConfigInject, useProfile, useProfileInject } from '@remake/hooks' import { useConfigInject, useRawProfile, useProfileInject } from '@remake/hooks'
import { get, set } from '@/storage' import { get, set } from '@/storage'
import { config } from '@/config' import { config } from '@/config'
@@ -10,35 +10,35 @@ export const useInit = () => {
const configInject = useConfigInject() const configInject = useConfigInject()
const profileInject = useProfileInject() const profileInject = useProfileInject()
const [inited, setInited] = useAtom(initedAtom) const [inited, setInited] = useAtom(initedAtom)
const [_, startTransition] = useTransition() const loader = useCallback(async () => {
const loader = useCallback(() => { if (inited) return
configInject(config) configInject(config)
startTransition(async () => { const { profile } = await get(['profile'])
const { profile } = await get(['profile']) const parsed = profile ? JSON.parse(profile) || {} : {}
const parsed = profile ? JSON.parse(profile) || {} : {} profileInject({
profileInject({ ...parsed,
...parsed, times: parsed.times || 0,
times: parsed.times || 0, achievements: new Set(parsed.achievements || []),
achievements: new Set(parsed.achievements || []), events: new Set(parsed.events || []),
events: new Set(parsed.events || []), talents: new Set(parsed.talents || []),
talents: new Set(parsed.talents || []),
})
setInited(true)
}) })
}, []) setInited(true)
}, [inited, configInject, profileInject, setInited])
return [inited, loader] as const return [inited, loader] as const
} }
export const useSaver = () => { export const useSaver = () => {
const [profile] = useProfile() const [profile] = useRawProfile()
const saver = useCallback(() => { const [inited] = useAtom(initedAtom)
return useCallback(async () => {
if (!inited || !profile) return
const str = JSON.stringify({ const str = JSON.stringify({
...profile,
times: profile.times, times: profile.times,
achievements: Array.from(profile.achievements), achievements: Array.from(profile.achievements),
events: Array.from(profile.events), events: Array.from(profile.events),
talents: Array.from(profile.talents), talents: Array.from(profile.talents),
}) })
set({ profile: str }) return await set({ profile: str })
}, [profile]) }, [inited, profile])
return saver
} }
-4
View File
@@ -1,8 +1,6 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { Game } from './Game' import { Game } from './Game'
import { ThemeToggle } from './components/ThemeToggle'
import { Jotai } from './jotai'
import './styles/colors.css' import './styles/colors.css'
import './styles/common.css' import './styles/common.css'
@@ -10,8 +8,6 @@ const container = document.getElementById('remake')!
const root = createRoot(container) const root = createRoot(container)
root.render( root.render(
<StrictMode> <StrictMode>
<Jotai />
<ThemeToggle />
<Game /> <Game />
</StrictMode>, </StrictMode>,
) )
-6
View File
@@ -1,6 +0,0 @@
import { useAtomsDevtools } from 'jotai-devtools/utils'
export function Jotai() {
useAtomsDevtools('Store')
return null
}
export default Jotai
+32
View File
@@ -1,3 +1,35 @@
if (localStorage.getItem('version') !== '3.0.0') {
const times = parseInt(localStorage.getItem('times') || '0')
if (times) {
const achievements = (
JSON.parse(localStorage.getItem('ACHV') || '[]') as [
number,
number,
][]
)
.sort((a, b) => a[1] - b[1])
.map(([id]) => id)
const events = JSON.parse(localStorage.getItem('AEVT') || '[]')
const talents = JSON.parse(localStorage.getItem('ATLT') || '[]')
const profile = { times, achievements, events, talents } as any
const lockedTalents = JSON.parse(
localStorage.getItem('extendTalent') || 'null',
)
if (lockedTalents) profile.lockedTalent = lockedTalents
const unique = JSON.parse(
localStorage.getItem('uniqueWaTaShi') || 'null',
)
if (unique) profile.unique = unique
localStorage.setItem('profile', JSON.stringify(profile))
localStorage.removeItem('times')
localStorage.removeItem('ACHV')
localStorage.removeItem('AEVT')
localStorage.removeItem('ATLT')
localStorage.removeItem('extendTalent')
localStorage.removeItem('uniqueWaTaShi')
}
localStorage.setItem('version', '3.0.0')
}
export async function get<Key extends string>(keys: Key[]) { export async function get<Key extends string>(keys: Key[]) {
return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as { return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as {
[K in Key]: string | null [K in Key]: string | null
+6 -1
View File
@@ -11,4 +11,9 @@
--color-primary: light-dark(#61b700, #b9ff69); --color-primary: light-dark(#61b700, #b9ff69);
--color-info: light-dark(#454ad3, #b5b7ff); --color-info: light-dark(#454ad3, #b5b7ff);
--color-error: light-dark(#e42b2b, #ff6969); --color-error: light-dark(#e42b2b, #ff6969);
} }
.grade-0 { --grade-color: var(--color-grade-0); }
.grade-1 { --grade-color: var(--color-grade-1); }
.grade-2 { --grade-color: var(--color-grade-2); }
.grade-3 { --grade-color: var(--color-grade-3); }
+12 -7
View File
@@ -2,6 +2,7 @@ html { transition: all 0.2s ease-in-out; }
html[data-theme='light'] { color-scheme: light; } html[data-theme='light'] { color-scheme: light; }
html[data-theme='dark'] { color-scheme: dark; } html[data-theme='dark'] { color-scheme: dark; }
:root { color: var(--base-text-color); } :root { color: var(--base-text-color); }
.hide-scrollbar,
html { html {
overflow-y: auto; overflow-y: auto;
overflow-x: auto; overflow-x: auto;
@@ -18,17 +19,21 @@ body {
margin: 1rem; margin: 1rem;
padding: 0; padding: 0;
background: var(--base-background-color); background: var(--base-background-color);
transition: all 0.2s ease-in-out;
}
html, body, button, input, select, textarea {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC", -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC",
"Noto Sans SC", "Noto Sans CJK SC", "Noto Sans", "Noto Sans SC", "Noto Sans CJK SC", "Noto Sans",
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.font-mono { .font-mono {
font-family: font-family:
"SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas,
"Noto Sans Mono CJK SC", "Noto Sans Mono", "Noto Code", "Noto Sans Mono CJK SC", "Noto Sans Mono", "Noto Code",
ui-monospace, monospace; ui-monospace, monospace;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -47,12 +52,12 @@ button {
border: var(--border-color) 0.0625rem solid; border: var(--border-color) 0.0625rem solid;
background: var(--background-color); background: var(--background-color);
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bold;
padding: 0 1rem; padding: 0 1rem;
line-height: 2.5rem; height: 2.5rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: Arial, sans-serif;
user-select: none; user-select: none;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
&:hover { &:hover {
@@ -86,4 +91,4 @@ input {
&::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
} }
@media (max-width: 32rem) { :root { font-size: 2.8vw; } } @media (max-width: 32rem) { :root { font-size: 2.88vw; } }
+5 -4
View File
@@ -26,14 +26,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "1.3.14", "@types/bun": "1.3.14",
"@typescript-eslint/eslint-plugin": "^8.66.0", "@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.66.0", "@typescript-eslint/parser": "^8.67.0",
"@typescript/native": "npm:typescript@^7.0.2", "@typescript/native": "npm:typescript@^7.0.2",
"eslint": "^10.8.0", "eslint": "^10.8.1",
"typescript": "npm:@typescript/typescript6@^6.0.2" "typescript": "npm:@typescript/typescript6@^6.0.2"
}, },
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*",
"thirdparty/*"
] ]
} }
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Achievement, Event, Talent } from '@remake/data' import type { Achievement, Event, Talent } from '@remake/data'
import { ages, AchievementOpportunity as Ao } from '@remake/data' import { ages, AchievementOpportunity as Ao } from '@remake/data'
import type { Allocation, GameState, ProfileState } from './state' import type { Allocation, GameState, ProfileState } from './state'
import type { Properties } from './state'
import { createState, nextProfile, propsEffect } from './state' import { createState, nextProfile, propsEffect } from './state'
import { summary as stateSummary } from './state' import { summary as stateSummary } from './state'
import type { PullOptions, ReplacementResult } from './talent' import type { PullOptions, ReplacementResult } from './talent'
@@ -109,6 +110,6 @@ export function end(
} }
export { pull, exclude } export { pull, exclude }
export type { GameState, ProfileState, Allocation, RNG } export type { GameState, ProfileState, Properties, RNG }
export type { PullOptions, ReplacementResult } export type { PullOptions, ReplacementResult, Allocation }
export type { AdditionalPoint, AdditionalPoints } export type { AdditionalPoint, AdditionalPoints }
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Achievement, Event, Talent } from '@remake/data' import type { Achievement, Event, Talent, Character } from '@remake/data'
import { produce } from 'immer' import { produce } from 'immer'
import { sum, keys } from '@remake/vitex' import { sum, keys } from '@remake/vitex'
@@ -37,6 +37,9 @@ export interface GameState {
talentTriggers: Map<Talent['id'], number> // 本局天赋触发次数 talentTriggers: Map<Talent['id'], number> // 本局天赋触发次数
} }
/** 唯一角色 */
export type UniqueCharacter = Omit<Character, 'id' | 'name'>
/** 持久化存储的数据 */ /** 持久化存储的数据 */
export interface ProfileState { export interface ProfileState {
times: number // 游戏次数 times: number // 游戏次数
@@ -46,6 +49,7 @@ export interface ProfileState {
achievements: Set<Achievement['id']> // 达成的成就 achievements: Set<Achievement['id']> // 达成的成就
highest?: Properties // 历史最高属性 highest?: Properties // 历史最高属性
lowest?: Properties // 历史最低属性 lowest?: Properties // 历史最低属性
unique?: UniqueCharacter // 唯一角色属性
} }
export function createState( export function createState(
+1
View File
@@ -7,6 +7,7 @@ export interface Config {
pull: PullOptions pull: PullOptions
points: number points: number
allocate: number allocate: number
spirit: number
mode: number mode: number
} }
+106 -26
View File
@@ -1,9 +1,11 @@
import { useTransition, useCallback } from 'react' import { useCallback, useRef, useState } from 'react'
import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai' import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai'
import { useConfig } from './config' import { useConfig } from './config'
import { useProfile } from './profile' import { useProfile } from './profile'
import { end } from '@remake/core' import { useReplaced, useTalentReset } from './talent'
import type { GameState } from '@remake/core' import { useAlloc, useAllocReset } from './alloc'
import { start, next, summary, end } from '@remake/core'
import type { GameState, Properties, NextResult } from '@remake/core'
import type { Talent } from '@remake/data/talent' import type { Talent } from '@remake/data/talent'
export enum Step { export enum Step {
@@ -15,45 +17,123 @@ export enum Step {
Summary = 'summary', Summary = 'summary',
} }
export type Log = Omit<NextResult, 'state'> & {
props: Omit<Properties, 'age'>
}
const stepAtom = atom<Step>(Step.Idle) const stepAtom = atom<Step>(Step.Idle)
const gameStateAtom = atom<GameState | null>(null) const gameStateAtom = atom<GameState | null>(null)
const logsAtom = atom<Log[]>([])
const summaryAtom = atom(0)
export const useStateReset = () => {
const setGameState = useSetAtom(gameStateAtom)
return useCallback(() => setGameState(null), [setGameState])
}
export const useLogsReset = () => {
const setLogs = useSetAtom(logsAtom)
return useCallback(() => setLogs([]), [setLogs])
}
export const useGameReset = () => {
const resetTalent = useTalentReset()
const resetAlloc = useAllocReset()
const resetState = useStateReset()
const resetLogs = useLogsReset()
return useCallback(() => {
resetTalent()
resetAlloc()
resetState()
resetLogs()
}, [resetTalent, resetAlloc, resetState, resetLogs])
}
export const useStep = () => useAtomValue(stepAtom) export const useStep = () => useAtomValue(stepAtom)
export const useSetStep = () => useSetAtom(stepAtom) export const useSetStep = () => useSetAtom(stepAtom)
export const useGameState = () => useAtomValue(gameStateAtom)
export const useSummary = () => useAtomValue(summaryAtom)
export const useLogs = () => useAtomValue(logsAtom)
export const useRemake = () => { export const useRemake = () => {
const config = useConfig() const { mode } = useConfig()
const [{ times }] = useProfile()
const setStep = useSetAtom(stepAtom)
const reset = useGameReset()
return useCallback(() => {
reset()
setStep(times < mode ? Step.Pick : Step.Mode)
}, [mode, times, setStep, reset])
}
export const useStart = () => {
const { spirit } = useConfig()
const [profile] = useProfile() const [profile] = useProfile()
const setStep = useSetAtom(stepAtom) const setStep = useSetAtom(stepAtom)
const setState = useSetAtom(gameStateAtom)
const allocate = useAlloc()
const { talents: tr } = useReplaced()
return useCallback(() => { return useCallback(() => {
const step = profile.times < config.mode ? Step.Pick : Step.Mode const alloc = { ...allocate, spirit }
setStep(step) const result = start(profile, alloc, tr.talents)
}, [config, profile, setStep]) setState(result.state)
setStep(Step.Play)
return result.achievements
}, [profile, allocate, spirit, tr, setStep, setState])
}
export const useNext = () => {
const [profile] = useProfile()
const [state, setState] = useAtom(gameStateAtom)
const [logs, setLogs] = useAtom(logsAtom)
const endRef = useRef(false)
const [ended, setEnded] = useState(false)
if (!state) throw new Error('Game state is not available.')
const nexter = useCallback(() => {
if (!state) throw new Error('Game state is not available.')
if (endRef.current) throw new Error('Game state is already ended.')
const { state: s, ...result } = next(state, profile)
setState(s)
const { age, ...props } = s.props.current
const log = { ...result, props }
setLogs(prev => [...prev, log])
if (s.life <= 0) {
endRef.current = true
setEnded(true)
}
return result.achievements
}, [state, profile, setState])
return [{ state, logs, ended }, nexter] as const
}
export const useGotoSummary = () => {
const [profile] = useProfile()
const [state, setState] = useAtom(gameStateAtom)
const setStep = useSetAtom(stepAtom)
const setSummary = useSetAtom(summaryAtom)
return useCallback(() => {
if (!state) throw new Error('Game state is not available.')
const result = summary(state, profile)
setSummary(result.summary)
setState(result.state)
setStep(Step.Summary)
return result.achievements
}, [state, profile, setStep, setState, setSummary])
} }
export const useEnd = () => { export const useEnd = () => {
const [profile, setProfile] = useProfile() const [profile, setProfile] = useProfile()
const [step, setStep] = useAtom(stepAtom) const [step, setStep] = useAtom(stepAtom)
const [state, setState] = useAtom(gameStateAtom) const state = useAtomValue(gameStateAtom)
const [pending, startTransition] = useTransition() const reset = useGameReset()
const ender = useCallback( return useCallback(
(talent?: Talent['id']) => { (talent?: Talent['id']) => {
startTransition(async () => { if (!state)
if (!state || step === Step.Summary) throw new Error('Game state is not available or already ended.')
throw new Error( const result = end(state, profile, talent)
'Game state is not available or already ended.', setStep(Step.Idle)
) setProfile(result.profile)
const { profile: next, achievements } = end( reset()
state, return result.achievements
profile,
talent,
)
setStep(Step.Idle)
setState(null)
setProfile(next)
})
}, },
[state, step, profile, setStep, setState, setProfile], [state, step, profile, setStep, setProfile, reset],
) )
return [pending, ender] as const
} }
+2
View File
@@ -4,6 +4,8 @@ import type { ProfileState } from '@remake/core'
const profileAtom = atom<ProfileState | null>(null) const profileAtom = atom<ProfileState | null>(null)
export const useRawProfile = () => useAtom(profileAtom)
export const useProfile = () => { export const useProfile = () => {
const [profile, setProfile] = useAtom(profileAtom) const [profile, setProfile] = useAtom(profileAtom)
if (!profile) throw new Error('Profile is not loaded') if (!profile) throw new Error('Profile is not loaded')
+15 -5
View File
@@ -10,6 +10,15 @@ import type { Talent } from '@remake/data/talent'
const pickedAtom = atom(new Set<Talent['id']>()) const pickedAtom = atom(new Set<Talent['id']>())
const replacedAtom = atom<TalentsPickedResult | null>(null) const replacedAtom = atom<TalentsPickedResult | null>(null)
export const useTalentReset = () => {
const setPicked = useSetAtom(pickedAtom)
const setReplaced = useSetAtom(replacedAtom)
return useCallback(() => {
setPicked(new Set())
setReplaced(null)
}, [setPicked, setReplaced])
}
export const usePicked = () => { export const usePicked = () => {
const picked = useAtomValue(pickedAtom) const picked = useAtomValue(pickedAtom)
if (!picked) throw new Error('No talents picked') if (!picked) throw new Error('No talents picked')
@@ -26,11 +35,12 @@ export const useTalentPuller = () => {
const [profile] = useProfile() const [profile] = useProfile()
const [pulled, setPulled] = useState<Talent['id'][] | null>(null) const [pulled, setPulled] = useState<Talent['id'][] | null>(null)
const puller = useCallback( const puller = useCallback(
(rng?: RNG) => setPulled(pull(p, profile, rng)), // (rng?: RNG) => setPulled(pull(p, profile, rng)),
// (rng?: RNG) => (rng?: RNG) =>
// setPulled([ setPulled([
// 1142, 1143, 1144, 1145, 1146, 1086, 1122, 1111, 1130, 1048, 1142, 1143, 1144, 1145, 1146, 1086, 1122, 1111, 1130, 1048,
// ]), 1033,
]),
[p, profile, setPulled], [p, profile, setPulled],
) )
return [pulled, puller] as const return [pulled, puller] as const
+8 -2
View File
@@ -34,6 +34,12 @@ export function shuffle<T>(array: T[], rng?: RNG): T[] {
return result return result
} }
export function keys<T extends object>(obj: T): (keyof T)[] { export function keys<T extends object, K extends keyof T = never>(
return Object.keys(obj) as (keyof T)[] obj: T,
filter?: K[],
): Exclude<keyof T, K>[] {
const allKeys = Object.keys(obj) as (keyof T)[]
if (!filter || filter.length === 0) return allKeys as Exclude<keyof T, K>[]
const filterSet = new Set<keyof T>(filter)
return allKeys.filter(key => !filterSet.has(key)) as Exclude<keyof T, K>[]
} }
+77 -574
View File
File diff suppressed because it is too large Load Diff