mirror of
https://github.com/VickScarlet/lifeRestart.git
synced 2026-08-19 18:43:53 +08:00
update: game core flow pages
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<meta name="keywords" content="人生重开模拟器 liferestart life restart remake 人生重来"/>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"eslint-config-react": "^1.1.7",
|
||||
"jotai-devtools": "^0.14.0",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
|
||||
+24
-1
@@ -1,6 +1,6 @@
|
||||
.screen {
|
||||
min-width: 32rem;
|
||||
max-width: calc(100dvw - 2rem);
|
||||
max-width: min(calc(100dvw - 2rem), 48rem);
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -9,4 +9,27 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
> * { 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useInit } from '@/hooks/storage'
|
||||
import { useEffect } from 'react'
|
||||
import { useInit, useSaver } from '@/hooks/storage'
|
||||
import { useStep, Step } from '@remake/hooks'
|
||||
import Home from '@/containers/Home'
|
||||
import Pick from '@/containers/TalentPick'
|
||||
@@ -11,10 +11,14 @@ import './Game.css'
|
||||
|
||||
export function Game() {
|
||||
const [inited, init] = useInit()
|
||||
const saver = useSaver()
|
||||
const step = useStep()
|
||||
useEffect(() => {
|
||||
if (!inited) init()
|
||||
}, [inited])
|
||||
useEffect(() => {
|
||||
saver()
|
||||
}, [saver])
|
||||
if (!inited) return <Loading />
|
||||
switch (step) {
|
||||
case Step.Idle:
|
||||
|
||||
@@ -4,28 +4,26 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
line-height: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1rem;
|
||||
gap: 1rem;
|
||||
color: var(--color);
|
||||
background: var(--background, var(--base-background-color));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
--color: var(--base-background-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.5) c h); }
|
||||
.talent-name { font-weight: bold; }
|
||||
.talent-description {
|
||||
color: oklch(from var(--color) calc(l + 0.1) c h);
|
||||
font-size: 0.6rem;
|
||||
align-self: flex-end;
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
&::before { content: '['; margin-right: 0.125rem; }
|
||||
&::after { content: ']'; margin-left: 0.125rem; }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Talent } from '@remake/data/talent'
|
||||
import talents from '@remake/data/talent'
|
||||
import cx from 'classnames'
|
||||
import './Talent.css'
|
||||
|
||||
export interface TalentProps {
|
||||
@@ -12,7 +11,7 @@ export function TalentComponent({ id, selected }: TalentProps) {
|
||||
if (!talent) return null
|
||||
const grade = talent?.grade ?? 0
|
||||
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-description">{talent.description}</span>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
--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);
|
||||
transform-origin: center;
|
||||
.sun-center, .sun-beams, .mask-cutter-group {
|
||||
@@ -26,21 +7,19 @@
|
||||
transform-origin: center;
|
||||
}
|
||||
.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-beams { stroke: oklch(from var(--base-text-color) calc(l + 0.2) c h); }
|
||||
.sun-center {
|
||||
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'] {
|
||||
.theme-toggle-btn { --hover-color: rgba(255, 255, 255, 0.1); }
|
||||
.mask-cutter-group { transform: translate(0, 0); }
|
||||
.theme-svg { transform: rotate(375deg); }
|
||||
.sun-center {
|
||||
transform: scale(1.5);
|
||||
fill: #fbbf24;
|
||||
}
|
||||
.sun-beams {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
stroke: #fbbf24;
|
||||
}
|
||||
.sun-center { transform: scale(1.5); }
|
||||
.sun-beams { opacity: 0; transform: scale(0.6); }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
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() {
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
)
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const storedTheme = localStorage.getItem('theme')
|
||||
if (storedTheme === 'dark') return true
|
||||
if (storedTheme === 'light') return false
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : 'light'
|
||||
updateThemeColor()
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
||||
}, [isDark])
|
||||
|
||||
return (
|
||||
|
||||
+73
-48
@@ -1,59 +1,84 @@
|
||||
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 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 = {
|
||||
mode: 10,
|
||||
pick: 3,
|
||||
mode: ModeLimit,
|
||||
pick: PickLimit,
|
||||
points: BasePoints,
|
||||
allocate: 10,
|
||||
spirit: 5,
|
||||
allocate: AllocLimit,
|
||||
spirit: DefaultSpirit,
|
||||
pull: {
|
||||
count: 10,
|
||||
count: PullCount,
|
||||
rate: {
|
||||
base: new Map([
|
||||
[0, 889],
|
||||
[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()
|
||||
},
|
||||
},
|
||||
base: PullRateBase,
|
||||
additions: PullRateAdditions,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,21 +8,23 @@
|
||||
.allocation-input {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 2rem;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
button {
|
||||
flex: 1 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
height: 100%;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
height: 2rem;
|
||||
flex: 1 0;
|
||||
height: 100%;
|
||||
width: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.points-detail {
|
||||
@@ -42,7 +44,8 @@
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
line-height: 2rem;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -72,9 +75,11 @@
|
||||
height: 2rem;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
border-bottom: none;
|
||||
text-align: center;
|
||||
line-height: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
> li.left {
|
||||
@@ -87,10 +92,9 @@
|
||||
> button {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
line-height: 2rem;
|
||||
height: 2rem;
|
||||
width: 6rem;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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 { properties } from '@/display'
|
||||
import { keys } from '@remake/vitex'
|
||||
@@ -59,9 +59,12 @@ export function Alloc() {
|
||||
const left = useLeftPoints()
|
||||
const [allocation, allocator] = useAllocator()
|
||||
const random = usePointRandomizer()
|
||||
const start = useStart()
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const handleNext = () => {
|
||||
// navigate('/life')
|
||||
const achievements = start()
|
||||
// TODO: Show achievements
|
||||
console.log('Achievements', achievements)
|
||||
}
|
||||
return (
|
||||
<div className="screen point-allocation">
|
||||
@@ -97,7 +100,7 @@ export function Alloc() {
|
||||
<button className="info" onClick={() => random()}>
|
||||
随机分配
|
||||
</button>
|
||||
<button className="primary" onClick={() => {}}>
|
||||
<button className="primary" onClick={handleNext}>
|
||||
开始新人生
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,22 @@
|
||||
h1 {
|
||||
font-size: 4rem;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRemake } from '@remake/hooks'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import './Home.css'
|
||||
|
||||
export default function Home() {
|
||||
@@ -12,6 +13,7 @@ export default function Home() {
|
||||
<button className="primary" onClick={remake}>
|
||||
立即重开
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,151 @@
|
||||
export default function Life() {
|
||||
return <></>
|
||||
import { useState, useRef } from 'react'
|
||||
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
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
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 (
|
||||
<div className="screen summary">
|
||||
<h2>人生总结</h2>
|
||||
<p>最终人生评分: {}</p>
|
||||
{/* 展现解锁的成就等 */}
|
||||
<button onClick={() => {}}>并入存档,重回首页</button>
|
||||
<ul className="judge-list">
|
||||
{judges.map(([key, { value, grade, level }]) => (
|
||||
<JudgeItem
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,4 +3,29 @@ export const properties = {
|
||||
intelligence: '智力',
|
||||
strength: '体质',
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useTransition } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
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 { config } from '@/config'
|
||||
|
||||
@@ -10,35 +10,35 @@ export const useInit = () => {
|
||||
const configInject = useConfigInject()
|
||||
const profileInject = useProfileInject()
|
||||
const [inited, setInited] = useAtom(initedAtom)
|
||||
const [_, startTransition] = useTransition()
|
||||
const loader = useCallback(() => {
|
||||
const loader = useCallback(async () => {
|
||||
if (inited) return
|
||||
configInject(config)
|
||||
startTransition(async () => {
|
||||
const { profile } = await get(['profile'])
|
||||
const parsed = profile ? JSON.parse(profile) || {} : {}
|
||||
profileInject({
|
||||
...parsed,
|
||||
times: parsed.times || 0,
|
||||
achievements: new Set(parsed.achievements || []),
|
||||
events: new Set(parsed.events || []),
|
||||
talents: new Set(parsed.talents || []),
|
||||
})
|
||||
setInited(true)
|
||||
const { profile } = await get(['profile'])
|
||||
const parsed = profile ? JSON.parse(profile) || {} : {}
|
||||
profileInject({
|
||||
...parsed,
|
||||
times: parsed.times || 0,
|
||||
achievements: new Set(parsed.achievements || []),
|
||||
events: new Set(parsed.events || []),
|
||||
talents: new Set(parsed.talents || []),
|
||||
})
|
||||
}, [])
|
||||
setInited(true)
|
||||
}, [inited, configInject, profileInject, setInited])
|
||||
return [inited, loader] as const
|
||||
}
|
||||
|
||||
export const useSaver = () => {
|
||||
const [profile] = useProfile()
|
||||
const saver = useCallback(() => {
|
||||
const [profile] = useRawProfile()
|
||||
const [inited] = useAtom(initedAtom)
|
||||
return useCallback(async () => {
|
||||
if (!inited || !profile) return
|
||||
const str = JSON.stringify({
|
||||
...profile,
|
||||
times: profile.times,
|
||||
achievements: Array.from(profile.achievements),
|
||||
events: Array.from(profile.events),
|
||||
talents: Array.from(profile.talents),
|
||||
})
|
||||
set({ profile: str })
|
||||
}, [profile])
|
||||
return saver
|
||||
return await set({ profile: str })
|
||||
}, [inited, profile])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { StrictMode } from 'react'
|
||||
import { Game } from './Game'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import { Jotai } from './jotai'
|
||||
import './styles/colors.css'
|
||||
import './styles/common.css'
|
||||
|
||||
@@ -10,8 +8,6 @@ const container = document.getElementById('remake')!
|
||||
const root = createRoot(container)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Jotai />
|
||||
<ThemeToggle />
|
||||
<Game />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useAtomsDevtools } from 'jotai-devtools/utils'
|
||||
export function Jotai() {
|
||||
useAtomsDevtools('Store')
|
||||
return null
|
||||
}
|
||||
export default Jotai
|
||||
@@ -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[]) {
|
||||
return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as {
|
||||
[K in Key]: string | null
|
||||
|
||||
@@ -11,4 +11,9 @@
|
||||
--color-primary: light-dark(#61b700, #b9ff69);
|
||||
--color-info: light-dark(#454ad3, #b5b7ff);
|
||||
--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); }
|
||||
@@ -2,6 +2,7 @@ html { transition: all 0.2s ease-in-out; }
|
||||
html[data-theme='light'] { color-scheme: light; }
|
||||
html[data-theme='dark'] { color-scheme: dark; }
|
||||
:root { color: var(--base-text-color); }
|
||||
.hide-scrollbar,
|
||||
html {
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
@@ -18,17 +19,21 @@ body {
|
||||
margin: 1rem;
|
||||
padding: 0;
|
||||
background: var(--base-background-color);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
html, body, button, input, select, textarea {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC",
|
||||
"Noto Sans SC", "Noto Sans CJK SC", "Noto Sans",
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "PingFang SC",
|
||||
"Noto Sans SC", "Noto Sans CJK SC", "Noto Sans",
|
||||
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family:
|
||||
"SF Mono", SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
font-family:
|
||||
"SF Mono", SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Noto Sans Mono CJK SC", "Noto Sans Mono", "Noto Code",
|
||||
ui-monospace, monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -47,12 +52,12 @@ button {
|
||||
border: var(--border-color) 0.0625rem solid;
|
||||
background: var(--background-color);
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
padding: 0 1rem;
|
||||
line-height: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: Arial, sans-serif;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
@@ -86,4 +91,4 @@ input {
|
||||
&::-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; } }
|
||||
Reference in New Issue
Block a user