Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d53a6ca33 | ||
|
|
907d0e7d09 | ||
|
|
ae5d318e9b | ||
|
|
d3f47dd7e9 | ||
|
|
5c951f4940 | ||
|
|
1e87b8fb34 | ||
|
|
31ea679a17 | ||
|
|
5c4c18a6fc | ||
|
|
32fd52532b | ||
|
|
e6e601eeb4 | ||
|
|
7679c79d30 | ||
|
|
2f5e05f937 | ||
|
|
9dc8a3e8bb | ||
|
|
a0e3f1b355 | ||
|
|
f4204601ee | ||
|
|
ad4e83fc47 | ||
|
|
afb4276dd1 | ||
|
|
46a8528864 | ||
|
|
7e863021c9 | ||
|
|
9ae4aa4ba2 | ||
|
|
1f455ba353 | ||
|
|
6eea7d0d87 | ||
|
|
cd82d175c2 | ||
|
|
533e705fae | ||
|
|
0c0fac5bfd | ||
|
|
25bd9ce2b7 | ||
|
|
dfdbfd69e7 | ||
|
|
322f775796 | ||
|
|
2f91709d1e | ||
|
|
9a0065c1e8 | ||
|
|
e531cb2ede | ||
|
|
bcfd24bbba | ||
|
|
32f703be0f | ||
|
|
85c871cec1 | ||
|
|
807a84de7f | ||
|
|
e646492e75 | ||
|
|
e035cdfe90 | ||
|
|
ce44237466 | ||
|
|
cf79e2763b | ||
|
|
05b59011ff | ||
|
|
0b69992037 | ||
|
|
90caa8ca92 | ||
|
|
4307904239 | ||
|
|
ce74c5221f | ||
|
|
cf95014a0c |
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
@@ -6,24 +6,32 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession.
|
||||
concurrency: ci-${{ github.ref }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
- name: 🛠️ Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 🛠️ Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: latest
|
||||
|
||||
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm xlsx2json
|
||||
pnpm build
|
||||
- name: 📦 Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Deploy 🚀
|
||||
- name: 🏗️ Build data
|
||||
run: bun build:data
|
||||
|
||||
- name: 🏗️ Build web
|
||||
run: bun build:web
|
||||
|
||||
- name: 🚀 Deploy
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
folder: template # The folder the action should deploy.
|
||||
folder: apps/web/dist # The folder the action should deploy.
|
||||
@@ -4,6 +4,12 @@
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Editor Browser",
|
||||
"request": "launch",
|
||||
"type": "editor-browser",
|
||||
"url": "http://localhost:5173",
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"js/ts.experimental.useTsgo": true
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM node:alpine
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL info
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["yarn", "dev"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 神戸小鳥
|
||||
Copyright (c) 2021-2026 神戸小鳥 and 神户小德
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,56 +1,19 @@
|
||||
# Life Restart
|
||||
# 人生重开模拟器
|
||||
|
||||
<a href="https://discord.gg/U3qrf49NMQ"><img src="https://img.shields.io/discord/883382868427014255?color=%23FEE75C&label=Discord&logo=discord&logoColor=white&style=for-the-badge" /></a>
|
||||
|
||||
[English](./README.md) | 简体中文
|
||||
|
||||
## 简介
|
||||
## 开发环境
|
||||
|
||||
- Game Life Restart
|
||||
包管理: `pnpm`
|
||||
运行时: `bun`
|
||||
|
||||
## 使用
|
||||
|
||||
<details>
|
||||
<summary><strong>网页版</strong></summary>
|
||||
<br />
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
# 1. 下载项目代码
|
||||
git clone https://github.com/VickScarlet/lifeRestart.git my-project
|
||||
|
||||
# 2. 进入目录安装依赖
|
||||
cd my-project
|
||||
pnpm install
|
||||
|
||||
# 3. 转换XLSX表
|
||||
pnpm xlsx2json
|
||||
|
||||
# 4. 启动本地开发服务器
|
||||
pnpm build:data
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
启动完成后打开浏览器访问 [http://localhost:5173](http://localhost:5173)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>控制台版本</strong></summary>
|
||||
<br />
|
||||
|
||||
```bash
|
||||
node repl
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 其他版本
|
||||
|
||||
<details>
|
||||
<summary><strong>版本列表</strong></summary>
|
||||
<br />
|
||||
|
||||
- Cocos版:[gameall3d/LifeRestart_Cocos](https://github.com/gameall3d/LifeRestart_Cocos)
|
||||
|
||||
</details>
|
||||
|
||||
> 更多信息请参考 [官网文档](https://liferestart.syaro.io/)。
|
||||
```
|
||||
@@ -1,56 +1,19 @@
|
||||
# Life Restart
|
||||
# Remake
|
||||
|
||||
<a href="https://discord.gg/U3qrf49NMQ"><img src="https://img.shields.io/discord/883382868427014255?color=%23FEE75C&label=Discord&logo=discord&logoColor=white&style=for-the-badge" /></a>
|
||||
|
||||
English | [简体中文](./README-zh_CN.md)
|
||||
|
||||
## Introduction
|
||||
## Environment
|
||||
|
||||
- Game Life Restart
|
||||
Package Manager: `pnpm`
|
||||
Runtime: `bun`
|
||||
|
||||
## Usage
|
||||
|
||||
<details>
|
||||
<summary><strong>Web Version</strong></summary>
|
||||
<br />
|
||||
|
||||
### Dev
|
||||
|
||||
```bash
|
||||
# 1. Clone project code.
|
||||
git clone git@github.com:VickScarlet/lifeRestart.git my-project
|
||||
cd my-project
|
||||
|
||||
# 2. Installation dependence.
|
||||
pnpm install
|
||||
|
||||
# 3. Transform XLSX
|
||||
pnpm xlsx2json
|
||||
|
||||
# 4. Start local dev server.
|
||||
pnpm build:data
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
After the startup is complete, open a browser and visit [http://localhost:5173](http://localhost:5173).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Command Line Version</strong></summary>
|
||||
<br />
|
||||
|
||||
```bash
|
||||
node repl
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Other Version
|
||||
|
||||
<details>
|
||||
<summary><strong>Versions</strong></summary>
|
||||
<br />
|
||||
|
||||
- Cocos Ver: [gameall3d/LifeRestart_Cocos](https://github.com/gameall3d/LifeRestart_Cocos)
|
||||
|
||||
</details>
|
||||
|
||||
> More instructions at [documentation](https://liferestart.syaro.io/).
|
||||
```
|
||||
@@ -4,8 +4,8 @@ import { dirname } from 'path'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
import { readFile } from 'fs/promises'
|
||||
import Life from '../src/modules/life.js'
|
||||
import $lang from '../src/i18n/zh-cn.js'
|
||||
import Life from '../srco/modules/life.js'
|
||||
import $lang from '../srco/i18n/zh-cn.js'
|
||||
globalThis.$lang = $lang
|
||||
|
||||
globalThis.json = async fileName =>
|
||||
@@ -235,7 +235,7 @@ class App {
|
||||
this.#talentExtend = localStorage.talentExtend
|
||||
await this.#life.initial(
|
||||
dataSet => json(`${this.#language}/${dataSet}`),
|
||||
json
|
||||
json,
|
||||
)
|
||||
this.output(
|
||||
`\rLoading Complete.
|
||||
@@ -244,14 +244,14 @@ class App {
|
||||
|
||||
键入 \x1B[4m/remake\x1B[24m 开始游戏
|
||||
键入 \x1B[4m/help\x1B[24m 获取帮助`,
|
||||
true
|
||||
true,
|
||||
)
|
||||
$$on('achievement', ({ name }) =>
|
||||
this.output(`
|
||||
-------------------------
|
||||
解锁成就【${name}】
|
||||
-------------------------
|
||||
`)
|
||||
`),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -561,19 +561,19 @@ class App {
|
||||
if (this.#talentSelected.has(s)) continue
|
||||
if (this.#talentSelected.size == 3)
|
||||
return warn(
|
||||
'你只能选3个天赋。请使用 \x1B[4m/unselect\x1B[24m 取消选择你不想要的天赋'
|
||||
'你只能选3个天赋。请使用 \x1B[4m/unselect\x1B[24m 取消选择你不想要的天赋',
|
||||
)
|
||||
|
||||
const exclusive = this.#life.exclude(
|
||||
Array.from(this.#talentSelected).map(({ id }) => id),
|
||||
s.id
|
||||
s.id,
|
||||
)
|
||||
|
||||
if (exclusive != null)
|
||||
for (const { name, id } of this.#talentSelected)
|
||||
if (id == exclusive)
|
||||
return warn(
|
||||
`天赋【${s.name}】与已选择的天赋【${name}】冲突`
|
||||
`天赋【${s.name}】与已选择的天赋【${name}】冲突`,
|
||||
)
|
||||
|
||||
this.#talentSelected.add(s)
|
||||
@@ -628,8 +628,8 @@ class App {
|
||||
`grade${talent.grade}b`,
|
||||
`${check(talent) ? '√' : ' '} ${i} ${talent.name}(${
|
||||
talent.description
|
||||
})`
|
||||
)
|
||||
})`,
|
||||
),
|
||||
),
|
||||
]
|
||||
.flat()
|
||||
@@ -645,7 +645,7 @@ class App {
|
||||
return warn(this.list(), `请选择 3 个天赋`)
|
||||
this.#step = this.Steps.PROPERTY
|
||||
this.#life.remake(
|
||||
Array.from(this.#talentSelected).map(({ id }) => id)
|
||||
Array.from(this.#talentSelected).map(({ id }) => id),
|
||||
)
|
||||
this.#propertyAllocation.total = this.#life.getPropertyPoints()
|
||||
return this.prop()
|
||||
@@ -673,15 +673,18 @@ class App {
|
||||
this.#interval = null
|
||||
this.#auto = false
|
||||
} else if (this.#auto) {
|
||||
this.#interval = setInterval(() => {
|
||||
const trajectory = this.next()
|
||||
if (this.#isEnd && this.#interval) {
|
||||
clearInterval(this.#interval)
|
||||
this.#interval = null
|
||||
}
|
||||
if (!this.#isEnd) return this.output(`${trajectory}\n`)
|
||||
return this.output(trajectory, true)
|
||||
}, Number(this.#auto) || 1000)
|
||||
this.#interval = setInterval(
|
||||
() => {
|
||||
const trajectory = this.next()
|
||||
if (this.#isEnd && this.#interval) {
|
||||
clearInterval(this.#interval)
|
||||
this.#interval = null
|
||||
}
|
||||
if (!this.#isEnd) return this.output(`${trajectory}\n`)
|
||||
return this.output(trajectory, true)
|
||||
},
|
||||
Number(this.#auto) || 1000,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -835,7 +838,7 @@ class App {
|
||||
return this.prop()
|
||||
case this.Steps.SUMMARY:
|
||||
this.#talentExtend = Array.from(this.#talentSelected).sort(
|
||||
() => Math.random() - 0.5
|
||||
() => Math.random() - 0.5,
|
||||
)[0].id
|
||||
return this.list()
|
||||
}
|
||||
@@ -847,7 +850,7 @@ class App {
|
||||
const { judge, grade, value } = summaryData[type]
|
||||
return this.style(
|
||||
`grade${grade}b`,
|
||||
`${name}:${value} ${$lang[judge]}`
|
||||
`${name}:${value} ${$lang[judge]}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@remake/console",
|
||||
"type": "module",
|
||||
"version": "3.0.0",
|
||||
"author": "Vick Scarlet <vick@syaro.io>",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"lint": "bunx --bun eslint .",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remake/core": "workspace:*",
|
||||
"@remake/data": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["**/*"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import defaultConfig from '../../eslint.config.ts'
|
||||
|
||||
export default defineConfig([
|
||||
defaultConfig,
|
||||
{ ignores: ['dist/**', 'node_modules/**'] },
|
||||
{
|
||||
...react.configs.flat.recommended!,
|
||||
settings: { react: { version: '19' } },
|
||||
},
|
||||
react.configs.flat['jsx-runtime']!,
|
||||
reactHooks.configs.flat.recommended,
|
||||
])
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="人生重开模拟器 - 这垃圾人生一秒也不想待了"/>
|
||||
<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>
|
||||
<div id="remake"></div>
|
||||
<script type="module" src="src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@remake/web",
|
||||
"type": "module",
|
||||
"version": "3.0.0",
|
||||
"author": "Vick Scarlet <vick@syaro.io>",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"dev:prod": "bunx --bun vite --mode production",
|
||||
"build": "bunx --bun vite build",
|
||||
"build:bili": "bunx --bun vite build --config vite.config.bili.ts",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"lint": "bunx --bun eslint",
|
||||
"test": "bunx --bun vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remake/data": "workspace:*",
|
||||
"@remake/hooks": "workspace:*",
|
||||
"@remake/vitex": "workspace:*",
|
||||
"@remake/thirdparty-bili-toy": "workspace:*",
|
||||
"jotai": "^2.20.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.screen {
|
||||
min-width: 32rem;
|
||||
max-width: min(calc(100dvw - 2rem), 48rem);
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
> * { width: 100%; }
|
||||
|
||||
> .section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(0.5rem * var(--ratio));
|
||||
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: calc(2rem * var(--ratio));
|
||||
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;
|
||||
}
|
||||
}
|
||||
> .controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
> button { flex: 1 0; }
|
||||
}
|
||||
&.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
> * { width: auto; }
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
.saving {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
user-select: none;
|
||||
&.active {
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useInit, useWatcher } from '@/hooks/storage'
|
||||
import { useStep, Step } from '@remake/hooks'
|
||||
import ToastContainer from '@/toast'
|
||||
import Home from '@/containers/Home'
|
||||
import Mode from '@/containers/Mode'
|
||||
import Chara from '@/containers/Chara'
|
||||
import Pick from '@/containers/Pick'
|
||||
import Alloc from '@/containers/Alloc'
|
||||
import Play from '@/containers/Play'
|
||||
import Summary from '@/containers/Summary'
|
||||
import Achv from '@/containers/Achv'
|
||||
import Thanks from '@/containers/Thanks'
|
||||
import './Game.css'
|
||||
|
||||
export function Container() {
|
||||
/* prettier-ignore */
|
||||
switch (useStep()) {
|
||||
case Step.Idle: return <Home />
|
||||
case Step.Mode: return <Mode />
|
||||
case Step.Chara: return <Chara />
|
||||
case Step.Pick: return <Pick />
|
||||
case Step.Alloc: return <Alloc />
|
||||
case Step.Play: return <Play />
|
||||
case Step.Summary: return <Summary />
|
||||
case Step.Achv: return <Achv />
|
||||
case Step.Thanks: return <Thanks />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/* prettier-ignore */
|
||||
const Loading = () => <div className="screen loading"><h2>载入中...</h2></div>
|
||||
const Saving = ({ active }: { active: boolean }) => (
|
||||
<div className={`saving ${active ? 'active' : ''}`}>保存中...</div>
|
||||
)
|
||||
|
||||
/* prettier-ignore */
|
||||
export function Game() {
|
||||
const [inited, init] = useInit()
|
||||
const saving = useWatcher()
|
||||
useEffect(() => { if (!inited) init() }, [inited, init])
|
||||
if (!inited) return <Loading />
|
||||
return <><Container /><ToastContainer /><Saving active={saving} /></>
|
||||
}
|
||||
export default Game
|
||||
@@ -0,0 +1,31 @@
|
||||
.achievement-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 calc(0.5rem * var(--ratio-font));
|
||||
height: calc(2rem * var(--ratio));
|
||||
gap: calc(1rem * var(--ratio-font));
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
color: var(--grade-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;
|
||||
svg {
|
||||
fill: var(--grade-color);
|
||||
transition: fill 0.2s ease-in-out;
|
||||
stroke: none;
|
||||
width: calc(1.2rem * var(--ratio-font));
|
||||
height: calc(1.2rem * var(--ratio-font));
|
||||
}
|
||||
.name { font-weight: bold; }
|
||||
.description {
|
||||
font-size: calc(0.85rem * var(--ratio-font));
|
||||
user-select: none;
|
||||
&::before { content: '['; margin-right: 0.125rem; }
|
||||
&::after { content: ']'; margin-left: 0.125rem; }
|
||||
&::before, &::after { color: var(--grade-color); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type Achievement as A, achievement } from '@remake/data'
|
||||
import './Achievement.css'
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 6.3500002 6.3500002"
|
||||
fill="currentColor"
|
||||
>
|
||||
<g>
|
||||
<path d="m 2.5032059,-0.0095735 c -0.043217,-6.35e-4 -0.0909,0.00531 -0.1493441,0.024289 C 2.2369795,0.0525379 2.1848433,0.1094109 2.1259683,0.1650936 2.0670958,0.2207129 2.0130256,0.2808305 1.9606037,0.3407933 1.8557572,0.4607694 1.7291409,0.5919178 1.7440793,0.5810887 1.7590282,0.5703466 1.5965582,0.6493864 1.4500399,0.7118302 1.3767847,0.7430018 1.3005529,0.7771219 1.2293826,0.8156998 1.1582044,0.8544035 1.0898255,0.885282 1.0175096,0.9846817 0.9451937,1.0840436 0.9358089,1.159797 0.9208732,1.2394466 0.9059507,1.319096 0.8985953,1.400403 0.8914171,1.479742 0.8770502,1.6384195 0.8500416,1.8161314 0.8557619,1.7985856 c 0.00572,-0.017558 -0.07988,0.1429176 -0.1617478,0.2795696 -0.040934,0.068351 -0.082447,0.141311 -0.1173056,0.214457 -0.034859,0.073146 -0.069638,0.1380463 -0.069763,0.2609662 -1.217e-4,0.1229198 0.03557,0.1882605 0.070279,0.2614827 0.034711,0.073247 0.07651,0.1434605 0.1173057,0.2118733 0.081592,0.1368131 0.1663991,0.2976496 0.1607158,0.2800863 -0.00569,-0.017526 0.019526,0.1632389 0.033589,0.3219442 0.00704,0.079296 0.014684,0.1606161 0.029456,0.2402954 0.014764,0.079742 0.022966,0.1557634 0.095083,0.2552817 0.072115,0.099531 0.1412954,0.130652 0.2123916,0.1694987 0.071099,0.038784 0.1469443,0.071021 0.2201413,0.1023191 0.1463886,0.062722 0.3099884,0.1442029 0.2950712,0.1333254 -0.014922,-0.010713 0.109855,0.1175901 0.2144581,0.2377115 0.0523,0.060092 0.1060847,0.1214847 0.1648486,0.1772502 0.058759,0.055816 0.1110827,0.1117997 0.2278909,0.1498616 0.1168056,0.03815 0.190463,0.023744 0.2707852,0.013435 0.080328,-0.010432 0.1605915,-0.026186 0.2382282,-0.043925 0.1552734,-0.035351 0.3341952,-0.068207 0.3157432,-0.068213 -0.018441,-1.85e-5 0.1595093,0.03148 0.3147086,0.06718 0.077602,0.017849 0.1568874,0.035993 0.2371963,0.046509 0.08029,0.010893 0.1544188,0.024387 0.2713012,-0.013436 C 4.1130209,5.0582357 4.1651597,5.0033666 4.2240321,4.9477472 4.2829046,4.8921276 4.3395598,4.8298801 4.3919791,4.7699803 4.4968281,4.6500042 4.6208595,4.521427 4.6059211,4.5322686 4.5909722,4.543008 4.7555086,4.4639712 4.9020269,4.4015274 4.9752821,4.3703558 5.0494475,4.3357186 5.1206178,4.2971408 5.1917933,4.258437 5.2601749,4.2280628 5.3324908,4.1286758 5.4048041,4.0293137 5.4141915,3.9535603 5.4291272,3.8739109 5.4440497,3.7942615 5.4514051,3.7129545 5.4585806,3.6336155 5.4729475,3.4749377 5.4999562,3.2946422 5.4942385,3.312188 5.4885235,3.3297458 5.5741162,3.1692831 5.6559863,3.0326187 5.69692,2.9642673 5.7384331,2.8938913 5.7732919,2.8207451 5.8081508,2.7475992 5.8429303,2.6827115 5.8430546,2.5597792 5.8431763,2.4368593 5.807484,2.3715183 5.7727734,2.2982965 5.7380653,2.2250492 5.6967824,2.152252 5.6559863,2.0838392 5.5743941,1.9470137 5.4890712,1.7882568 5.4947545,1.8058201 5.500443,1.8233461 5.4752282,1.6425809 5.4611656,1.4838759 5.4541542,1.404454 5.4464812,1.3232598 5.4317096,1.2435805 5.4169458,1.163838 5.4087437,1.0878171 5.3366262,0.9882988 5.2645114,0.8887679 5.1953308,0.8581762 5.1242346,0.8193169 5.0531358,0.7805332 4.977285,0.7458378 4.9040933,0.7144138 4.7577047,0.6516914 4.5941022,0.5728705 4.6090221,0.5836723 4.6239446,0.5943853 4.499683,0.4630319 4.39508,0.3428602 4.3427798,0.282768 4.2884767,0.2219049 4.2297154,0.1661267 4.1709567,0.1103108 4.1186327,0.0543271 4.0018218,0.0162652 3.8850188,-0.0218851 3.8113668,-0.0055365 3.7310393,0.004896 3.6507118,0.0153285 3.5704478,0.0315991 3.4928111,0.0493378 3.3375377,0.0846891 3.1586212,0.1174185 3.1770679,0.1175506 3.1955094,0.1175694 3.0175559,0.0834873 2.8623566,0.0477876 2.784757,0.0299388 2.7054693,0.0138614 2.6251629,0.0033458 2.5850177,-0.002102 2.5464229,-0.0089361 2.5032059,-0.0095735 Z m 0.6810957,1.3916462 a 0.26407552,0.26514563 1.664302 0 1 0.2098067,0.1069702 l 0.3064404,0.4149617 0.5167657,0.1953371 a 0.26407552,0.26514563 1.664302 0 1 0.1209225,0.403076 L 4.0395461,2.9240974 4.0142255,3.4780688 A 0.26407552,0.26514563 1.664302 0 1 3.6721272,3.7188812 L 3.1791343,3.563335 2.6468667,3.7090625 A 0.26407552,0.26514563 1.664302 0 1 2.3109702,3.4548146 L 2.3068427,2.9364998 2.0050537,2.4734789 A 0.26407552,0.26514563 1.664302 0 1 2.1404463,2.0760872 L 2.6324046,1.9133065 2.9781198,1.4807747 a 0.26407552,0.26514563 1.664302 0 1 0.1865524,-0.098185 0.26407552,0.26514563 1.664302 0 1 0.019632,-5.292e-4 z" />
|
||||
<path d="m 1.32395,4.6940153 v 0.8454266 c 0,0.22689 0.085796,0.4353634 0.2320264,0.5999633 0.073102,0.0823 0.1640284,0.155757 0.2831888,0.1953369 0.1191578,0.0396 0.2710842,0.033444 0.396875,-0.029456 L 3.1755175,5.8365814 4.1149947,6.3052865 a 0.26460996,0.26460996 0 0 0 0.1188561,0.027389 c 0.4351602,0 0.7932315,-0.3580833 0.7932315,-0.7932332 V 4.6976326 c -0.1063255,0.047962 -0.2399666,0.1090639 -0.2284096,0.1007693 0.01696,-0.0123 -0.1239096,0.1336209 -0.2428795,0.2697509 -0.059468,0.06796 -0.1238859,0.1384379 -0.1906852,0.2015381 -0.066799,0.0631 -0.1257724,0.1255652 -0.2583815,0.168465 -0.1326198,0.0429 -0.2168922,0.027386 -0.3079909,0.014986 -0.09112,-0.011999 -0.1806178,-0.03241 -0.2687188,-0.05271 -0.1760908,-0.0405 -0.3779838,-0.075984 -0.3570843,-0.075964 0.020929,0 -0.1824646,0.036898 -0.3586348,0.076998 -0.08809,0.0201 -0.1791679,0.038326 -0.2702666,0.050126 -0.09113,0.0117 -0.1744398,0.027797 -0.306959,-0.015502 C 2.1045317,5.3929098 2.0448735,5.3293743 1.9781747,5.2660743 1.9114944,5.2028043 1.8504047,5.133253 1.7911063,5.0650529 1.6724248,4.9287629 1.5313251,4.7831021 1.5482267,4.795302 1.5595777,4.803572 1.4276667,4.741909 1.32395,4.6940153 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
export interface AchievementProps {
|
||||
id: A['id']
|
||||
}
|
||||
export function Achievement({ id }: AchievementProps) {
|
||||
const ach = achievement.get(id)
|
||||
if (!ach) return null
|
||||
const grade = ach?.grade ?? 0
|
||||
return (
|
||||
<div className={`achievement-item grade-${grade}`}>
|
||||
<Icon />
|
||||
<span className="name">{ach.name}</span>
|
||||
<span className="description">{ach.description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Achievement
|
||||
@@ -0,0 +1,12 @@
|
||||
button.github-btn {
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--style-color);
|
||||
transition: fill 0.2s ease-in-out;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
&:hover svg { fill: var(--style-background); }
|
||||
}
|
||||
&:active svg{ fill: var(--style-background); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { toastMsg } from '@/toast'
|
||||
import './Github.css'
|
||||
const repo = 'https://github.com/VickScarlet/remake'
|
||||
export function Github() {
|
||||
const handleClick = () => {
|
||||
if (import.meta.env.VITE_CHANNEL === 'bili') toastMsg(repo, 'github')
|
||||
else window.open(repo)
|
||||
}
|
||||
return (
|
||||
<button className="github-btn" onClick={handleClick}>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9a127.5 127.5 0 0 1 38.1 91v112.5c.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
export default Github
|
||||
@@ -0,0 +1,10 @@
|
||||
.message-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
color: var(--base-text-color);
|
||||
background: var(--base-background-color);
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import './Message.css'
|
||||
|
||||
export interface MsgToastProps {
|
||||
message: React.ReactNode
|
||||
}
|
||||
export function MsgToast({ message }: MsgToastProps) {
|
||||
return <div className="message-item">{message}</div>
|
||||
}
|
||||
|
||||
export default MsgToast
|
||||
@@ -0,0 +1,50 @@
|
||||
ul.replaced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
.talent {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
li:not(:first-child) {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
border-left: none;
|
||||
right: 0;
|
||||
top: -1rem;
|
||||
margin-right: -1rem;
|
||||
width: 0.625rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
right: 0;
|
||||
top: 0.5rem;
|
||||
margin-right: -0.6875rem;
|
||||
margin-top: -0.03125rem;
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
transform: rotate(-45deg);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type Talent as T, talents } from '@remake/data'
|
||||
import Talent from './Talent'
|
||||
import './Replaced.css'
|
||||
|
||||
export interface TalentProps {
|
||||
id: T['id']
|
||||
chains?: T['id'][]
|
||||
}
|
||||
export function Replaced({ id, chains }: TalentProps) {
|
||||
return (
|
||||
<ul className="replaced">
|
||||
<li>
|
||||
<Talent id={id} selected={true} />
|
||||
</li>
|
||||
{chains?.map(id => {
|
||||
const talent = talents.get(id)
|
||||
if (!talent) return null
|
||||
return (
|
||||
<li key={id}>
|
||||
<Talent id={id} selected={false} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default Replaced
|
||||
@@ -0,0 +1,62 @@
|
||||
.talent {
|
||||
--color: var(--grade-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
height: calc(2rem * var(--ratio));
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
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;
|
||||
&.selected {
|
||||
--color: var(--base-background-color);
|
||||
--background: var(--grade-color);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover { background: oklch(from var(--grade-color) calc(l + 0.1) c h); }
|
||||
}
|
||||
&:active {
|
||||
background: oklch(from var(--grade-color) calc(l + 0.1) c h);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) {
|
||||
&:hover { background: oklch(from var(--grade-color) calc(l + 0.5) c h); }
|
||||
}
|
||||
&:active {
|
||||
background: oklch(from var(--grade-color) calc(l + 0.5) c h);
|
||||
transition: none;
|
||||
}
|
||||
.talent-name { font-weight: bold; }
|
||||
.talent-description {
|
||||
color: oklch(from var(--color) calc(l + 0.1) c h);
|
||||
font-size: calc(0.85rem * var(--ratio-font));
|
||||
user-select: none;
|
||||
&::before { content: '['; margin-right: 0.125rem; }
|
||||
&::after { content: ']'; margin-left: 0.125rem; }
|
||||
&::before, &::after { color: var(--color); }
|
||||
}
|
||||
}
|
||||
html[data-theme='dark'] {
|
||||
.talent{
|
||||
@media (hover: hover) {
|
||||
&:hover { background: oklch(from var(--grade-color) calc(l - 0.5) c h); }
|
||||
&.selected:hover { background: oklch(from var(--grade-color) calc(l - 0.1) c h); }
|
||||
}
|
||||
&:active {
|
||||
background: oklch(from var(--grade-color) calc(l - 0.5) c h);
|
||||
transition: none;
|
||||
}
|
||||
&.selected:active {
|
||||
background: oklch(from var(--grade-color) calc(l - 0.1) c h);
|
||||
transition: none;
|
||||
}
|
||||
.talent-description { color: oklch(from var(--color) calc(l - 0.2) c h); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type Talent as T, talents } from '@remake/data'
|
||||
import './Talent.css'
|
||||
|
||||
export interface TalentProps {
|
||||
id: T['id']
|
||||
selected?: boolean
|
||||
}
|
||||
export function Talent({ id, selected }: TalentProps) {
|
||||
const talent = talents.get(id)
|
||||
if (!talent) return null
|
||||
const grade = talent?.grade ?? 0
|
||||
return (
|
||||
<div className={`talent grade-${grade} ${selected ? 'selected' : ''}`}>
|
||||
<span className="talent-name">{talent.name}</span>
|
||||
<span className="talent-description">{talent.description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Talent
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
export interface Props {
|
||||
text: string
|
||||
className: string
|
||||
}
|
||||
|
||||
export function TextSvg({ text, className }: Readonly<Props>) {
|
||||
const [height, setHeight] = useState(0)
|
||||
const [width, setWidth] = useState(0)
|
||||
const textRef = useRef<SVGTextElement>(null)
|
||||
useEffect(() => {
|
||||
if (!textRef.current) return
|
||||
const { width, height } = textRef.current.getBBox()
|
||||
setWidth(width)
|
||||
setHeight(height)
|
||||
}, [text, className, textRef])
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
<text
|
||||
ref={textRef}
|
||||
fontSize={10}
|
||||
style={{
|
||||
transform: 'translate(50%,50%)',
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'central',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export default TextSvg
|
||||
@@ -0,0 +1,37 @@
|
||||
.theme-svg {
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: center;
|
||||
.sun-center, .sun-beams, .mask-cutter-group {
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: center;
|
||||
}
|
||||
.mask-cutter-group { transform: translate(0.75rem, -0.75rem); }
|
||||
.sun-center {
|
||||
transition: fill 0.2s ease-in-out;
|
||||
fill: var(--style-color);
|
||||
}
|
||||
.sun-beams {
|
||||
transition: stroke 0.2s ease-in-out;
|
||||
stroke: var(--style-color);
|
||||
}
|
||||
}
|
||||
button.theme-toggle-btn {
|
||||
@media (hover: hover) {
|
||||
&:hover .theme-svg{
|
||||
.sun-center { fill: var(--style-background); }
|
||||
.sun-beams { stroke: var(--style-background); }
|
||||
}
|
||||
}
|
||||
|
||||
&:active .theme-svg{
|
||||
.sun-center { fill: var(--style-background); }
|
||||
.sun-beams { stroke: var(--style-background); }
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
.mask-cutter-group { transform: translate(0, 0); }
|
||||
.theme-svg { transform: rotate(375deg); }
|
||||
.sun-center { transform: scale(1.5); }
|
||||
.sun-beams { opacity: 0; transform: scale(0.6); }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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(() => {
|
||||
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 (
|
||||
<button
|
||||
className="theme-toggle-btn"
|
||||
onClick={() => setIsDark(!isDark)}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
className="theme-svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<defs>
|
||||
<mask id="ios-fail-proof-mask">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
<g className="mask-cutter-group">
|
||||
<circle cx="18" cy="5" r="7" fill="#000000" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<circle
|
||||
className="sun-center"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="5"
|
||||
mask="url(#ios-fail-proof-mask)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<g className="sun-beams" stroke="currentColor">
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Config } from '@remake/hooks'
|
||||
export const dev = {} as Record<string, any>
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
new URLSearchParams(window.location.search)
|
||||
.entries()
|
||||
.forEach(([key, value]) => {
|
||||
if (key !== 'locked') return (dev[key] = Number(value))
|
||||
dev[key] = value.split(',').map(v => Number(v))
|
||||
})
|
||||
}
|
||||
//
|
||||
export const Features = dev.features ?? 1
|
||||
// 自动模式自动点击间隔
|
||||
export const AutoInterval = dev.interval ?? 500
|
||||
// 可锁定的天赋数量
|
||||
export const LockLimit = dev.lock ?? 1
|
||||
// 名人模式重开次数限制
|
||||
export const ModeLimit = dev.mode ?? 10
|
||||
// 天赋选择数量限制
|
||||
export const PickMax = dev.max ?? 3
|
||||
// 天赋选择最少数量限制
|
||||
export const PickMin = dev.min ?? 3
|
||||
// 基础属性点
|
||||
export const BasePoints = dev.points ?? 20
|
||||
// 单项属性点最大限制
|
||||
export const AllocLimit = dev.allocate ?? 10
|
||||
// 默认初始快乐
|
||||
export const DefaultSpirit = dev.spirit ?? 5
|
||||
// 天赋抽取个数
|
||||
export const PullCount = dev.pull ?? 10
|
||||
// 名人模式抽取个数
|
||||
export const CharacterPullCount = dev.chara ?? 3
|
||||
// 名人模式抽取权重切线
|
||||
export const CharacterWeightKnife = dev.knife ?? 10
|
||||
// 唯一角色生成抽卡次数限制
|
||||
export const UniqueLimit = dev.unique ?? 10
|
||||
|
||||
// 天赋抽取基础概率
|
||||
export const PullRateBase: Config['pull']['rate']['base'] = new Map([
|
||||
[0, 889],
|
||||
[1, 100],
|
||||
[2, 10],
|
||||
[3, 1],
|
||||
])
|
||||
// 属性评级线
|
||||
export const JudgeLineMap = {
|
||||
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, 30, 60, 90],
|
||||
event: [0, 20, 40, 60],
|
||||
}
|
||||
// 可评级属性
|
||||
export type Judges = keyof typeof JudgeLineMap
|
||||
// 获取属性评级
|
||||
export function judge(key: Judges, value: number) {
|
||||
const arr = JudgeLineMap[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 function judgeGradeByValue(key: Judges, value: number) {
|
||||
const level = judge(key, value)
|
||||
return judgeGrade(key, level)
|
||||
}
|
||||
|
||||
// 天赋抽取概率加成
|
||||
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]])
|
||||
},
|
||||
}
|
||||
|
||||
// 权重生成器
|
||||
function wg(s: number, e: number) {
|
||||
const length = e - s + 1
|
||||
return Array.from(
|
||||
{ length },
|
||||
(_, i) => [s + i, Math.min(i + 1, length - i)] as const,
|
||||
)
|
||||
}
|
||||
// 唯一角色属性分配权重
|
||||
export const UniquePropWeight = wg(0, 10)
|
||||
// 唯一角色天赋个数权重
|
||||
export const UniqueTalentWeight = wg(1, 5)
|
||||
|
||||
export const config: Config = {
|
||||
features: Features,
|
||||
lock: LockLimit,
|
||||
mode: ModeLimit,
|
||||
max: PickMax,
|
||||
min: PickMin,
|
||||
points: BasePoints,
|
||||
allocate: AllocLimit,
|
||||
spirit: DefaultSpirit,
|
||||
pull: {
|
||||
count: PullCount,
|
||||
rate: {
|
||||
base: PullRateBase,
|
||||
additions: PullRateAdditions,
|
||||
},
|
||||
},
|
||||
chara: {
|
||||
count: CharacterPullCount,
|
||||
knife: CharacterWeightKnife,
|
||||
},
|
||||
unique: {
|
||||
limit: UniqueLimit,
|
||||
config: {
|
||||
prop: UniquePropWeight,
|
||||
talent: UniqueTalentWeight,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,119 @@
|
||||
.screen.achv {
|
||||
height: calc(100dvh - 2rem);
|
||||
width: min(calc(100dvw - 2rem), 48rem);
|
||||
gap: 1rem;
|
||||
> .stats {
|
||||
> .details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
color: var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
user-select: none;
|
||||
> span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
> .value {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
> .prg {
|
||||
padding-bottom: 0.1875rem;
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 0.1875rem;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translateZ(-1px);
|
||||
}
|
||||
&::after {
|
||||
width: var(--progress, 0%);
|
||||
background: var(--grade-color);
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
&::before {
|
||||
width: 100%;
|
||||
border-top: 0.0625rem solid var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
> .achievement-list {
|
||||
flex: 1 0;
|
||||
width: 100%;
|
||||
> ul {
|
||||
flex: 1 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
> li.achievement {
|
||||
margin-right: 0.0625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--grade-color);
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
padding: 0 0.0625rem;
|
||||
box-sizing: border-box;
|
||||
transform-style: preserve-3d;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
>.name {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
>.description {
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
&::before{
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 0.0625rem;
|
||||
margin: -0.0625rem;
|
||||
transform: translateZ(1px);
|
||||
background: var(--base-background-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
&.colled {
|
||||
&::before{
|
||||
transform: translateZ(-1px);
|
||||
background: var(--grade-color);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useAchv, useGoHome } from '@remake/hooks'
|
||||
import { judge, judgeGrade, judgeGradeByValue } from '@/config'
|
||||
import { rates } from '@/display'
|
||||
import { achievements, type Achievement } from '@remake/data'
|
||||
import { toastMsg } from '@/toast'
|
||||
import './Achv.css'
|
||||
|
||||
interface AchievementProps {
|
||||
id: Achievement['id']
|
||||
colled: boolean
|
||||
}
|
||||
export function AchievementItem({ id, colled }: AchievementProps) {
|
||||
const { name, grade, description, hide } = achievements.get(id)!
|
||||
return (
|
||||
<li className={`achievement grade-${grade} ${colled ? 'colled' : ''}`}>
|
||||
<span className="name">{hide && !colled ? '???' : name}</span>
|
||||
<span className="description">
|
||||
{hide && !colled ? '???' : description}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Achv() {
|
||||
const goHome = useGoHome()
|
||||
const showRank = () => toastMsg('别卷了,没有排行榜', 'achv-rank')
|
||||
const [stats, sorted] = useAchv()
|
||||
const jtimes = judge('times', stats.times)
|
||||
const gtimes = judgeGrade('times', jtimes)
|
||||
const jachv = judge('achievements', stats.achv)
|
||||
const gachv = judgeGrade('achievements', jachv)
|
||||
const gevent = judgeGradeByValue('event', stats.event)
|
||||
const revent = stats.event.toFixed(2) + '%'
|
||||
const gtalent = judgeGradeByValue('talent', stats.talent)
|
||||
const rtalent = stats.talent.toFixed(2) + '%'
|
||||
return (
|
||||
<div className="screen achv">
|
||||
<div className="controls">
|
||||
<button className="secondary" onClick={showRank}>
|
||||
排行榜
|
||||
</button>
|
||||
<button className="primary" onClick={goHome}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="section stats">
|
||||
<div className="title">统计</div>
|
||||
<div className="details">
|
||||
<div className={`grade-${gtimes}`}>
|
||||
<span className="title">已重开{stats.times}次</span>
|
||||
<span className="value">{rates.times[jtimes]}</span>
|
||||
</div>
|
||||
<div className={`grade-${gachv}`}>
|
||||
<span className="title">已收集成就{stats.achv}个</span>
|
||||
<span className="value">
|
||||
{rates.achievement[jachv]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`prg grade-${gevent}`}
|
||||
style={{ '--progress': revent } as React.CSSProperties}
|
||||
>
|
||||
<span className="title">事件收集率</span>
|
||||
<span className="value">{revent}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`prg grade-${gtalent}`}
|
||||
style={{ '--progress': rtalent } as React.CSSProperties}
|
||||
>
|
||||
<span className="title">天赋收集率</span>
|
||||
<span className="value">{rtalent}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section achievement-list hide-scrollbar">
|
||||
<div className="title">成就</div>
|
||||
<ul className="details hide-scrollbar">
|
||||
{sorted.map(({ id, colled }) => (
|
||||
<AchievementItem key={id} id={id} colled={colled} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Achv
|
||||
@@ -0,0 +1,123 @@
|
||||
.screen.point-allocation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
.allocation-input {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: calc(2rem * var(--ratio));
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
color: var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease-in-out;
|
||||
button {
|
||||
--style-color: var(--grade-color);
|
||||
flex: 1 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
input {
|
||||
flex: 1 0;
|
||||
height: 100%;
|
||||
width: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--grade-color);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.points-detail {
|
||||
min-width: 100%;
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
position: absolute;
|
||||
flex-direction: column;
|
||||
white-space: nowrap;
|
||||
background: var(--base-background-color);
|
||||
border: 0.0625rem solid var(--color-primary);
|
||||
box-sizing: border-box;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(-100%);
|
||||
margin: 0;
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: calc(2rem * var(--ratio));
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
li:not(:first-child) {
|
||||
border-top: 0.0625rem solid var(--color-primary);
|
||||
}
|
||||
}
|
||||
> ul.alloc {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
> li {
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
> span {
|
||||
width: 100%;
|
||||
height: calc(2rem * var(--ratio));
|
||||
color: var(--grade-color);
|
||||
background: var(--base-background-color);
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
> span.name {
|
||||
color: var(--base-background-color);
|
||||
background: var(--grade-color);
|
||||
}
|
||||
}
|
||||
> li.left {
|
||||
--left-color: var(--color-error);
|
||||
&.left-0 { --left-color: var(--color-primary); }
|
||||
> span {
|
||||
background: var(--left-color);
|
||||
border: 0.0625rem solid var(--left-color);
|
||||
}
|
||||
> button {
|
||||
--style-color: var(--left-color);
|
||||
flex: 1;
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
height: calc(2rem * var(--ratio));
|
||||
width: 6rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
> ul.talent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
li { width: 100%; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks'
|
||||
import { usePicked, useReplaced, useStart, useIsClassic } from '@remake/hooks'
|
||||
import type { AdditionalPoint } from '@remake/hooks'
|
||||
import { properties } from '@/display'
|
||||
import { keys } from '@remake/vitex'
|
||||
import { judgeGradeByValue } from '@/config'
|
||||
import { talents } from '@remake/data'
|
||||
import { toastAchvs, toastMsg } from '@/toast'
|
||||
import Replaced from '@/components/Replaced'
|
||||
import './Alloc.css'
|
||||
|
||||
interface AllocInputProps {
|
||||
point: number
|
||||
onChange: (point: number) => void
|
||||
}
|
||||
|
||||
function AllocInput(props: AllocInputProps) {
|
||||
return (
|
||||
<div className="allocation-input">
|
||||
<button onClick={() => props.onChange(props.point - 1)}>−</button>
|
||||
<input
|
||||
className="font-mono"
|
||||
type="number"
|
||||
value={props.point}
|
||||
onChange={e => props.onChange(Number(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => props.onChange(props.point + 1)}>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PointsDetailProps {
|
||||
base: number
|
||||
source: AdditionalPoint[]
|
||||
fixed?: boolean
|
||||
}
|
||||
|
||||
function PointsDetail({ base, source, fixed }: PointsDetailProps) {
|
||||
return (
|
||||
<ul className="points-detail">
|
||||
<li>
|
||||
<span>{fixed ? '固定' : '基础'}</span>
|
||||
<span className="font-mono">{base}</span>
|
||||
</li>
|
||||
{source.map(({ talent, points }) => (
|
||||
<li key={talent}>
|
||||
<span>{talents.get(talent)?.name ?? talent}</span>
|
||||
<span className="font-mono">{points}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function Alloc() {
|
||||
const isClassic = useIsClassic()
|
||||
const picked = usePicked()
|
||||
const {
|
||||
talents: { chains },
|
||||
additionalPoints: { source },
|
||||
} = useReplaced()
|
||||
const { base, left } = useLeftPoints()
|
||||
const [{ alloc, final, base: ba }, allocator] = useAllocator()
|
||||
const random = usePointRandomizer()
|
||||
const start = useStart()
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const handleNext = () => {
|
||||
if (left != 0) return toastMsg('还有剩余点数未分配', 'alloc-toast')
|
||||
const achievements = start()
|
||||
toastAchvs(achievements)
|
||||
}
|
||||
return (
|
||||
<div className="screen point-allocation">
|
||||
<ul className="talent-list">
|
||||
{Array.from(picked, id => (
|
||||
<li key={id}>
|
||||
<Replaced id={id} chains={chains.get(id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className={`alloc ${isClassic ? 'classic' : 'modify'}`}>
|
||||
<li className={`left left-${left}`}>
|
||||
<span className="name">剩余点数</span>
|
||||
<button
|
||||
className="font-mono"
|
||||
onClick={() => setShowDetail(!showDetail)}
|
||||
>
|
||||
{left}
|
||||
</button>
|
||||
{showDetail && (
|
||||
<PointsDetail
|
||||
base={base}
|
||||
source={source}
|
||||
fixed={!isClassic}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
{keys(alloc).map(key => (
|
||||
<li
|
||||
key={key}
|
||||
className={`property grade-${judgeGradeByValue(key, final[key])}`}
|
||||
>
|
||||
<span className="name">
|
||||
{properties[key]}
|
||||
{!isClassic && (
|
||||
<span className="font-mono">[{ba[key]}]</span>
|
||||
)}
|
||||
</span>
|
||||
{!isClassic && (
|
||||
<span className="font-mono">{final[key]}</span>
|
||||
)}
|
||||
<AllocInput
|
||||
point={alloc[key]}
|
||||
onChange={value => allocator(key, value)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="controls">
|
||||
<button className="secondary" onClick={() => random()}>
|
||||
随机分配
|
||||
</button>
|
||||
<button
|
||||
className={left ? 'error' : 'primary'}
|
||||
onClick={handleNext}
|
||||
>
|
||||
开始新人生
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Alloc
|
||||
@@ -0,0 +1,91 @@
|
||||
.screen.chara {
|
||||
gap: 1rem;
|
||||
> .chara-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(0.5rem * var(--ratio));
|
||||
> .character {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
> .generator > ul > li { text-align: center; }
|
||||
> .name {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
height: calc(2rem * var(--ratio));
|
||||
}
|
||||
&.selected {
|
||||
border: 0.0625rem solid var(--color-primary);
|
||||
> .name {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--base-background-color);
|
||||
}
|
||||
> .chara-details {
|
||||
height: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chara-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border-top: 0.0625rem solid var(--base-text-color);
|
||||
box-sizing: border-box;
|
||||
height: 0;
|
||||
padding: 0 0.5rem;
|
||||
overflow-y: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
interpolate-size: allow-keywords;
|
||||
> ul.talent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
> 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;
|
||||
color: var(--grade-color);
|
||||
span {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
height: 2rem;
|
||||
&:first-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
.name {
|
||||
color: var(--base-background-color);
|
||||
background-color: var(--grade-color);
|
||||
}
|
||||
&.charm { order: 1 }
|
||||
&.intelligence { order: 2 }
|
||||
&.strength { order: 3 }
|
||||
&.money { order: 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCharaPuller, useCharaPicker, useCharaSubmit } from '@remake/hooks'
|
||||
import { useUnique, useUniqueGenerator, convertProps } from '@remake/hooks'
|
||||
import type { BaseChara } from '@remake/hooks'
|
||||
import { judgeGradeByValue } from '@/config'
|
||||
import { characters } from '@remake/data'
|
||||
import { properties } from '@/display'
|
||||
import { keys } from '@remake/vitex'
|
||||
import { toastMsg } from '@/toast'
|
||||
import Talent from '@/components/Talent'
|
||||
import './Chara.css'
|
||||
|
||||
function Details({ detail }: { detail: BaseChara }) {
|
||||
const props = convertProps(detail.property)
|
||||
return (
|
||||
<div className="chara-details">
|
||||
<ul className="properties">
|
||||
{keys(props).map(key => (
|
||||
<li
|
||||
key={key}
|
||||
className={`property ${key} grade-${judgeGradeByValue(key, props[key])}`}
|
||||
>
|
||||
<span>{properties[key]}</span>
|
||||
<span className="font-mono">{props[key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="talent-list">
|
||||
{detail.talent.map(id => (
|
||||
<li key={id}>
|
||||
<Talent id={id} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UniqueProps {
|
||||
selected?: boolean
|
||||
picker?: () => void
|
||||
}
|
||||
function Unique({ selected, picker }: UniqueProps) {
|
||||
const [unique, generator] = useUniqueGenerator()
|
||||
return (
|
||||
<li
|
||||
className={`character ${selected ? 'selected' : ''}`}
|
||||
onClick={picker}
|
||||
>
|
||||
<span className="name">独一无二的我</span>
|
||||
{unique && <Details detail={unique} />}
|
||||
{!unique && (
|
||||
<div className="chara-details generator">
|
||||
<ul>
|
||||
<li>6000万玩家中独一无二的角色卡</li>
|
||||
<li>所有属性 所有天赋 随机生成</li>
|
||||
<li>每人只能生成一次</li>
|
||||
</ul>
|
||||
<button
|
||||
className="primary focus"
|
||||
onClick={() => generator()}
|
||||
>
|
||||
生成唯一角色
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
interface CharacterProps {
|
||||
id: number
|
||||
selected?: boolean
|
||||
picker?: (id: number) => void
|
||||
}
|
||||
function Character({ id, selected, picker }: CharacterProps) {
|
||||
const character = characters.get(id)!
|
||||
return (
|
||||
<li
|
||||
className={`character ${selected ? 'selected' : ''}`}
|
||||
onClick={() => picker?.(id)}
|
||||
>
|
||||
<span className="name">{character.name}</span>
|
||||
<Details detail={character} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chara() {
|
||||
const u = useUnique()
|
||||
const [{ unique, characters }, puller] = useCharaPuller()
|
||||
const [picked, picker] = useCharaPicker()
|
||||
const submit = useCharaSubmit()
|
||||
const handleSubmit = () => {
|
||||
if (ready) return submit(picked)
|
||||
if (!picked) toastMsg('请先选择角色', 'chara-toast-pick')
|
||||
else toastMsg('请先生成唯一角色', 'chara-toast-unique')
|
||||
}
|
||||
const ready = picked && (picked.type === 'unique' ? !!u : true)
|
||||
return (
|
||||
<div className="screen chara">
|
||||
<ul className="chara-list">
|
||||
{unique && (
|
||||
<Unique
|
||||
selected={picked?.type === 'unique'}
|
||||
picker={() => picker.unique()}
|
||||
/>
|
||||
)}
|
||||
{characters.map(id => (
|
||||
<Character
|
||||
key={id}
|
||||
id={id}
|
||||
selected={picked?.id === id}
|
||||
picker={id => picker.chara(id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className="controls">
|
||||
<button className="secondary" onClick={puller}>
|
||||
都不是
|
||||
</button>
|
||||
<button
|
||||
className={ready ? 'primary' : 'error'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
开始新人生
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chara
|
||||
@@ -0,0 +1,34 @@
|
||||
.screen.home {
|
||||
gap: 5rem;
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.main { font-weight: bold; }
|
||||
}
|
||||
> .actions {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
>button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
>.controls {
|
||||
flex-direction: column;
|
||||
> div {
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
> button { flex: 1 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useRemake, useFeatures, useGoAchv, useGoThanks } from '@remake/hooks'
|
||||
import { TextSvg } from '@/components/TextSvg'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import Github from '@/components/Github'
|
||||
import './Home.css'
|
||||
|
||||
export default function Home() {
|
||||
const remake = useRemake()
|
||||
const features = useFeatures()
|
||||
const goAchv = useGoAchv()
|
||||
const goThanks = useGoThanks()
|
||||
return (
|
||||
<div className="screen home">
|
||||
<div className="title">
|
||||
<TextSvg text="人生重开模拟器" className="main" />
|
||||
<TextSvg text="这垃圾人生一秒也不想待了" className="sub" />
|
||||
</div>
|
||||
<div className="controls">
|
||||
<div>
|
||||
<button className="primary focus" onClick={remake}>
|
||||
立即重开
|
||||
</button>
|
||||
</div>
|
||||
{features && (
|
||||
<div>
|
||||
<button className="secondary" onClick={goAchv}>
|
||||
成就
|
||||
</button>
|
||||
<button className="secondary" onClick={goThanks}>
|
||||
感谢
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions">
|
||||
{features && <Github />}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.screen.mode {
|
||||
gap: 1rem;
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 10rem;
|
||||
gap: 0.5rem;
|
||||
.name {
|
||||
font-size: 3.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
span {
|
||||
font-size: 1rem;
|
||||
&::before { content: '['; }
|
||||
&::after { content: ']'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { PullCount } from '@/config'
|
||||
import { useModeChoose } from '@remake/hooks'
|
||||
import './Mode.css'
|
||||
|
||||
export function Mode() {
|
||||
const [Mode, choose] = useModeChoose()
|
||||
return (
|
||||
<div className="screen mode">
|
||||
<button onClick={() => choose(Mode.Classic)}>
|
||||
<div className="name">经典模式</div>
|
||||
<div className="description">
|
||||
<span>{PullCount} 连抽天赋</span>
|
||||
<span>自由分配属性</span>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => choose(Mode.Celebrity)}>
|
||||
<div className="name">名人模式</div>
|
||||
<div className="description">
|
||||
<span>前世古代名人,重开到了现代</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Mode
|
||||
@@ -0,0 +1,22 @@
|
||||
.screen.talent-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
ul.talent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(0.5rem * var(--ratio));
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
.talent {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTalentPuller, useTalentPicker } from '@remake/hooks'
|
||||
import { useTalentSubmit, useSubmitIsEnable } from '@remake/hooks'
|
||||
import { PullCount, dev } from '@/config'
|
||||
import { toastMsg } from '@/toast'
|
||||
import Talent from '@/components/Talent'
|
||||
import './Pick.css'
|
||||
|
||||
export function Pick() {
|
||||
const [pulled, puller] = useTalentPuller()
|
||||
const [talents, picker] = useTalentPicker()
|
||||
const { enabled, min, max } = useSubmitIsEnable()
|
||||
const submit = useTalentSubmit()
|
||||
const msg = enabled
|
||||
? '下一步'
|
||||
: `请选取 ${min == max ? min : `${min}~${max}`} 个天赋`
|
||||
const handleSubmit = () => {
|
||||
if (enabled) return submit()
|
||||
toastMsg(msg, 'pick-toast')
|
||||
}
|
||||
if (!pulled)
|
||||
return (
|
||||
<div className="screen talent-pick">
|
||||
<button
|
||||
className="primary font-mono focus"
|
||||
onClick={() => puller()}
|
||||
>
|
||||
{PullCount} 连抽!
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
const ps = dev.locked
|
||||
? Array.from(new Set([...dev.locked, ...pulled]))
|
||||
: pulled
|
||||
return (
|
||||
<div className="screen talent-pick">
|
||||
<ul className="talent-list">
|
||||
{ps.map(id => (
|
||||
<li key={id} onClick={() => picker(id)}>
|
||||
<Talent id={id} selected={talents.has(id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className={enabled ? 'primary' : 'error'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{msg}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Pick
|
||||
@@ -0,0 +1,165 @@
|
||||
.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;
|
||||
color: var(--grade-color);
|
||||
span {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 0.0625rem solid var(--grade-color);
|
||||
box-sizing: border-box;
|
||||
height: 2rem;
|
||||
&:first-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
.name {
|
||||
color: var(--base-background-color);
|
||||
background-color: var(--grade-color);
|
||||
}
|
||||
&.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: calc(1.25rem * var(--ratio-font));
|
||||
height: calc(2.5rem * var(--ratio));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.screen.play > ul.properties > li {
|
||||
.value {
|
||||
position: relative;
|
||||
background-color: var(--base-background-color);
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
&[class*="trend-up"] .value::after,
|
||||
&[class*="trend-down"] .value::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-style: solid;
|
||||
animation: triangle-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
&[class*="trend-up"] .value::after {
|
||||
border-width: 0 0.3125rem 0.375rem 0.3125rem;
|
||||
border-color: transparent transparent var(--color-success) transparent;
|
||||
}
|
||||
&[class*="trend-down"] .value::after {
|
||||
border-width: 0.375rem 0.3125rem 0 0.3125rem;
|
||||
border-color: var(--color-error) transparent transparent transparent;
|
||||
}
|
||||
&[class*="trend-up-1"] .value { animation: flash-success-odd 0.5s ease-out forwards; }
|
||||
&[class*="trend-up-0"] .value { animation: flash-success-even 0.5s ease-out forwards; }
|
||||
&[class*="trend-down-1"] .value { animation: flash-error-odd 0.5s ease-out forwards; }
|
||||
&[class*="trend-down-0"] .value { animation: flash-error-even 0.5s ease-out forwards; }
|
||||
}
|
||||
|
||||
@keyframes flash-success-odd {
|
||||
0% { background-color: color-mix(in srgb, var(--color-success) 30%, var(--base-background-color)); }
|
||||
100% { background-color: var(--base-background-color); }
|
||||
}
|
||||
@keyframes flash-success-even {
|
||||
0% { background-color: color-mix(in srgb, var(--color-success) 30%, var(--base-background-color)); }
|
||||
100% { background-color: var(--base-background-color); }
|
||||
}
|
||||
@keyframes flash-error-odd {
|
||||
0% { background-color: color-mix(in srgb, var(--color-error) 30%, var(--base-background-color)); }
|
||||
100% { background-color: var(--base-background-color); }
|
||||
}
|
||||
@keyframes flash-error-even {
|
||||
0% { background-color: color-mix(in srgb, var(--color-error) 30%, var(--base-background-color)); }
|
||||
100% { background-color: var(--base-background-color); }
|
||||
}
|
||||
@keyframes triangle-pop {
|
||||
0% { opacity: 0; transform: translateY(-50%) scale(0.5); }
|
||||
100% { opacity: 1; transform: translateY(-50%) scale(1); }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useLayoutEffect, useEffect } from 'react'
|
||||
import { useNext, useGotoSummary, type Log } from '@remake/hooks'
|
||||
import { useJudge } from '@/hooks/judge'
|
||||
import { achievements, events, talents } from '@remake/data'
|
||||
import { properties } from '@/display'
|
||||
import { AutoInterval } from '@/config'
|
||||
import { format } from '@remake/vitex'
|
||||
import { toastAchvs } from '@/toast/Achv'
|
||||
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>
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
interface LogEventProps {
|
||||
id: number
|
||||
post: boolean
|
||||
index: number
|
||||
}
|
||||
function LogEvent({ id, post, index }: LogEventProps) {
|
||||
let { event, postEvent, grade, format: f } = events.get(id)!
|
||||
if (f) {
|
||||
const g = (key: string) => ({ CurrentYear: year + index })[key]
|
||||
event = format(event, g)
|
||||
if (post && postEvent) postEvent = format(postEvent, g)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<li className={`grade-${grade}`}>{event}</li>
|
||||
{post && postEvent && (
|
||||
<li className={`grade-${grade}`}>{postEvent}</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LogEvents({ items, index }: { items: number[]; index: number }) {
|
||||
const last = items.length - 1
|
||||
const els = items.map((id, i) => (
|
||||
<LogEvent key={id} id={id} post={i == last} index={index} />
|
||||
))
|
||||
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>
|
||||
}
|
||||
|
||||
function Log({ log, index }: { log: Log; index: number }) {
|
||||
return (
|
||||
<li className="log">
|
||||
<span className="age font-mono">{log.age}岁</span>
|
||||
<div className="content">
|
||||
<LogTalents items={log.talents} />
|
||||
<LogEvents items={log.events} index={index} />
|
||||
<LogAchievements items={log.achievements} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
interface PropProps {
|
||||
prop: keyof typeof properties
|
||||
value: number
|
||||
grade: number
|
||||
}
|
||||
|
||||
function Prop({ prop, value, grade }: PropProps) {
|
||||
const prevRef = useRef<number>(value)
|
||||
const [trend, setTrend] = useState<'up' | 'down' | 'normal'>('normal')
|
||||
const [flip, setFlip] = useState(0)
|
||||
const [displayValue, setDisplayValue] = useState<number>(value)
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current
|
||||
if (value === prev) return
|
||||
const startValue = prevRef.current
|
||||
prevRef.current = value
|
||||
setTrend(value > prev ? 'up' : 'down')
|
||||
setFlip(f => (f + 1) % 2)
|
||||
let startTimestamp: number | null = null
|
||||
const duration = 400
|
||||
let end = false
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTimestamp) startTimestamp = timestamp
|
||||
const progress = timestamp - startTimestamp
|
||||
const progressRatio = Math.min(progress / duration, 1)
|
||||
const easeOutQuad = progressRatio * (2 - progressRatio)
|
||||
const currentDec = startValue + (value - startValue) * easeOutQuad
|
||||
setDisplayValue(Math.round(currentDec))
|
||||
if (!end && progress < duration) requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
const timer = setTimeout(() => setTrend('normal'), 2000)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
setDisplayValue(value)
|
||||
end = true
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<li className={`${prop} grade-${grade} trend-${trend}-${flip}`}>
|
||||
<span className="name">{properties[prop]}</span>
|
||||
<span className="value font-mono">{displayValue}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Properties() {
|
||||
const judges = useJudge()
|
||||
return (
|
||||
<ul className="properties">
|
||||
{judges.map(([key, { value, grade }]) => (
|
||||
<Prop key={key} prop={key} value={value} grade={grade} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function Play() {
|
||||
const [{ logs, ended }, next] = useNext()
|
||||
const [auto, setAuto] = useState(false)
|
||||
const logRef = useRef<HTMLUListElement>(null)
|
||||
const autoRef = useRef(0)
|
||||
const gotoSummary = useGotoSummary()
|
||||
const handleNext = useCallback(() => {
|
||||
if (ended) return
|
||||
const achievements = next()
|
||||
toastAchvs(achievements)
|
||||
}, [ended, next])
|
||||
const handleGotoSummary = useCallback(() => {
|
||||
if (!ended) return
|
||||
const achievements = gotoSummary()
|
||||
toastAchvs(achievements)
|
||||
}, [ended, gotoSummary])
|
||||
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">
|
||||
<Properties />
|
||||
<ul
|
||||
className="logs hide-scrollbar"
|
||||
onClick={handleNext}
|
||||
ref={logRef}
|
||||
>
|
||||
{logs.map((log, index) => (
|
||||
<Log key={index} log={log} index={index} />
|
||||
))}
|
||||
</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: calc(0.5rem * var(--ratio));
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePicked, useEnd, useProfile, useIsClassic } from '@remake/hooks'
|
||||
import { useEndJudge } from '@/hooks/judge'
|
||||
import { properties, judgeDisplay } from '@/display'
|
||||
import { toastAchvs, toastMsg } from '@/toast'
|
||||
import Talent from '@/components/Talent'
|
||||
import './Summary.css'
|
||||
|
||||
function Judges() {
|
||||
const judges = useEndJudge()
|
||||
return (
|
||||
<ul className="judge-list">
|
||||
{judges.map(([key, { value, grade, level }]) => (
|
||||
<li className={`${key} grade-${grade}`} key={key}>
|
||||
<span className="property">{properties[key]}</span>
|
||||
<span className="value font-mono">{value}</span>
|
||||
<span className="level">{judgeDisplay(key, level)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
interface TalentListProps {
|
||||
picked: Set<number>
|
||||
picker: (id: number) => void
|
||||
}
|
||||
function TalentList({ picked, picker }: TalentListProps) {
|
||||
const [profile] = useProfile()
|
||||
const talents = usePicked()
|
||||
const hasInitialized = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!profile.locked) return
|
||||
if (!hasInitialized.current) {
|
||||
for (const id of profile.locked) {
|
||||
if (talents.has(id) && !picked.has(id)) {
|
||||
picker(id)
|
||||
}
|
||||
}
|
||||
hasInitialized.current = true
|
||||
}
|
||||
}, [profile.locked, talents, picked, picker])
|
||||
|
||||
return (
|
||||
<ul className="talent-list">
|
||||
{Array.from(talents, id => (
|
||||
<li key={id} onClick={() => picker(id)}>
|
||||
<Talent id={id} selected={picked.has(id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Summary() {
|
||||
const isClassic = useIsClassic()
|
||||
const [locked, picker, end] = useEnd()
|
||||
const handlePicker = (id: number) => {
|
||||
if (isClassic) picker(id)
|
||||
else toastMsg('名人天赋不可锁定', 'summary-toast')
|
||||
}
|
||||
const handleEnd = () => {
|
||||
const achievements = end()
|
||||
toastAchvs(achievements)
|
||||
}
|
||||
return (
|
||||
<div className="screen summary">
|
||||
<Judges />
|
||||
<div className="section">
|
||||
<div className="title">
|
||||
{isClassic
|
||||
? '你可以锁定一个天赋,下辈子还能抽到'
|
||||
: '名人天赋不可锁定'}
|
||||
</div>
|
||||
<TalentList picked={locked} picker={handlePicker} />
|
||||
</div>
|
||||
<button className="primary" onClick={handleEnd}>
|
||||
再次重开
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
.screen.thanks {
|
||||
height: calc(100dvh - 2rem);
|
||||
gap: 1rem;
|
||||
.person {
|
||||
--style-color: var(--custom-color, var(--base-text-color));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--style-color);
|
||||
background: var(--base-background-color);
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
> .name {
|
||||
display: flex;
|
||||
text-wrap: wrap;
|
||||
word-break: break-all;
|
||||
min-height: 2.5rem;
|
||||
width: 12rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
align-items: center;
|
||||
text-shadow:
|
||||
-0.0625rem -0.0625rem 0.0625rem var(--base-shadow),
|
||||
0.0625rem -0.0625rem 0.0625rem var(--base-shadow),
|
||||
-0.0625rem 0.0625rem 0.0625rem var(--base-shadow),
|
||||
0.0625rem 0.0625rem 0.0625rem var(--base-shadow),
|
||||
0 -0.0625rem 0.0625rem var(--base-shadow),
|
||||
0 0.0625rem 0.0625rem var(--base-shadow),
|
||||
-0.0625rem 0 0.0625rem var(--base-shadow),
|
||||
0.0625rem 0 0.0625rem var(--base-shadow);
|
||||
}
|
||||
> .comment {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
min-height: 2.5rem;
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-shadow:
|
||||
-0.0625rem -0.0625rem 0.0625rem var(--base-shadow),
|
||||
0.0625rem -0.0625rem 0.0625rem var(--base-shadow),
|
||||
-0.0625rem 0.0625rem 0.0625rem var(--base-shadow),
|
||||
0.0625rem 0.0625rem 0.0625rem var(--base-shadow),
|
||||
0 -0.0625rem 0.0625rem var(--base-shadow),
|
||||
0 0.0625rem 0.0625rem var(--base-shadow),
|
||||
-0.0625rem 0 0.0625rem var(--base-shadow),
|
||||
0.0625rem 0 0.0625rem var(--base-shadow);
|
||||
}
|
||||
}
|
||||
> .group-1 {
|
||||
flex: 1 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
> .person {
|
||||
margin: 0 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
border: 0.0625rem solid var(--style-color);
|
||||
box-sizing: border-box;
|
||||
> .comment {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
min-height: 2.5rem;
|
||||
flex: 1 0;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
> .group-2 {
|
||||
flex: 3 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
border: 0.0625rem solid var(--base-text-color);
|
||||
> .person {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-wrap: wrap;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
margin-right: 0.0625rem;
|
||||
> .name { justify-content: center; }
|
||||
> .comment {display: none;}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useGoHome } from '@remake/hooks'
|
||||
import { specialThanks, SpecialThanksGroup as Group } from '@remake/data'
|
||||
import type { SpecialThanks } from '@remake/data'
|
||||
import { shuffle } from '@remake/vitex'
|
||||
import './Thanks.css'
|
||||
|
||||
const groups = Map.groupBy(specialThanks, item => item.group)
|
||||
|
||||
export function Person({ item }: { item: SpecialThanks }) {
|
||||
return (
|
||||
<li
|
||||
className="person"
|
||||
style={
|
||||
{
|
||||
'--custom-color': item.color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="name">{item.name}</span>
|
||||
{item.comment && <span className="comment">{item.comment}</span>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Thanks() {
|
||||
const goHome = useGoHome()
|
||||
const group1 = shuffle(groups.get(Group.G1) ?? [])
|
||||
const group2 = shuffle(groups.get(Group.G2) ?? [])
|
||||
return (
|
||||
<div className="screen thanks">
|
||||
<button className="primary" onClick={goHome}>
|
||||
返回
|
||||
</button>
|
||||
<ul className="group group-1 hide-scrollbar">
|
||||
{group1.map((item, index) => (
|
||||
<Person key={index} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<ul className="group group-2 hide-scrollbar">
|
||||
{group2.map((item, index) => (
|
||||
<Person key={index} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Thanks
|
||||
@@ -0,0 +1,31 @@
|
||||
export const properties = {
|
||||
charm: '颜值',
|
||||
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,46 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSummary, useGameState } from '@remake/hooks'
|
||||
import { judge, judgeGrade } from '@/config'
|
||||
import type { Judges } from '@/config'
|
||||
import { keys } from '@remake/vitex'
|
||||
|
||||
export interface Judge {
|
||||
value: number
|
||||
level: number
|
||||
grade: number
|
||||
}
|
||||
function judges<T extends Judges, K extends keyof Record<T, number> = never>(
|
||||
props: Record<T, number>,
|
||||
filter?: K[],
|
||||
) {
|
||||
const judges: [Exclude<T, K>, Judge][] = []
|
||||
for (const key of keys(props, filter)) {
|
||||
const value = props[key]
|
||||
const level = judge(key, value)
|
||||
const grade = judgeGrade(key, level)
|
||||
judges.push([key, { value, level, grade }])
|
||||
}
|
||||
return judges
|
||||
}
|
||||
|
||||
export const useJudge = () => {
|
||||
const state = useGameState()
|
||||
if (!state) throw new Error('Game state is not set')
|
||||
const current = state.props.current
|
||||
return useMemo(() => {
|
||||
const result = judges(current, ['age'])
|
||||
return result
|
||||
}, [current])
|
||||
}
|
||||
|
||||
export const useEndJudge = () => {
|
||||
const summary = useSummary()
|
||||
const state = useGameState()
|
||||
if (!state) throw new Error('Game state is not set')
|
||||
const highest = state.props.highest
|
||||
return useMemo(() => {
|
||||
const props = { ...highest, summary }
|
||||
const result = judges(props)
|
||||
return result
|
||||
}, [highest, summary])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useCallback, useTransition } from 'react'
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { useConfigInject, useProfileInject, useRawProfile } from '@remake/hooks'
|
||||
import { useUniqueInject, useUnique } from '@remake/hooks'
|
||||
import { init, get, set } from '@/storage'
|
||||
import { config } from '@/config'
|
||||
|
||||
const initedAtom = atom(false)
|
||||
|
||||
export const useInit = () => {
|
||||
const configInject = useConfigInject()
|
||||
const profileInject = useProfileInject()
|
||||
const uniqueInject = useUniqueInject()
|
||||
const [inited, setInited] = useAtom(initedAtom)
|
||||
const loader = useCallback(async () => {
|
||||
if (inited) return
|
||||
configInject(config)
|
||||
await init()
|
||||
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 || []),
|
||||
})
|
||||
const unique = await get('unique')
|
||||
if (unique) uniqueInject(JSON.parse(unique))
|
||||
setInited(true)
|
||||
}, [inited, configInject, profileInject, uniqueInject, setInited])
|
||||
return [inited, loader] as const
|
||||
}
|
||||
|
||||
export const useWatcher = () => {
|
||||
const [profile] = useRawProfile()
|
||||
const unique = useUnique()
|
||||
const [inited] = useAtom(initedAtom)
|
||||
const [p, saveProfile] = useTransition()
|
||||
const [u, saveUnique] = useTransition()
|
||||
useEffect(() => {
|
||||
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),
|
||||
})
|
||||
saveProfile(async () => {
|
||||
await set('profile', str)
|
||||
})
|
||||
}, [inited, profile, saveProfile])
|
||||
useEffect(() => {
|
||||
if (!inited || !unique) return
|
||||
const str = JSON.stringify(unique)
|
||||
saveUnique(async () => {
|
||||
await set('unique', str)
|
||||
})
|
||||
}, [inited, unique, saveUnique])
|
||||
|
||||
return p || u
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { StrictMode } from 'react'
|
||||
import { Game } from './Game'
|
||||
import './styles/colors.css'
|
||||
import './styles/common.css'
|
||||
|
||||
const container = document.getElementById('remake')!
|
||||
const root = createRoot(container)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Game />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test, expect, describe } from 'vitest'
|
||||
import { talents, achievements, events } from '@remake/data'
|
||||
|
||||
describe('Bili Toy Cloud Save Core', () => {
|
||||
test('maxSizeProfile should pass ToyCloudSaveCore constraints', () => {
|
||||
const json = JSON.stringify({
|
||||
times: 999999,
|
||||
locked: Array.from(talents.keys()),
|
||||
talents: Array.from(talents.keys()),
|
||||
achievements: Array.from(achievements.keys()),
|
||||
events: Array.from(events.keys()),
|
||||
})
|
||||
expect(json.length).toBeLessThan((48 * 960 * 3) / 4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as biliToy from '@remake/thirdparty-bili-toy'
|
||||
import * as local from './local'
|
||||
|
||||
interface Storage {
|
||||
init(): Promise<void>
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string): Promise<boolean>
|
||||
}
|
||||
const storage = {} as Storage
|
||||
|
||||
if (import.meta.env.VITE_CHANNEL === 'bili') {
|
||||
storage.init = biliToy.init
|
||||
storage.get = biliToy.get
|
||||
storage.set = biliToy.set
|
||||
} else {
|
||||
storage.init = async () => {}
|
||||
storage.get = local.get
|
||||
storage.set = local.set
|
||||
}
|
||||
|
||||
export const init = storage.init
|
||||
export const get = storage.get
|
||||
export const set = storage.set
|
||||
@@ -0,0 +1,39 @@
|
||||
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.locked = [lockedTalents]
|
||||
localStorage.setItem('profile', JSON.stringify(profile))
|
||||
localStorage.removeItem('times')
|
||||
localStorage.removeItem('ACHV')
|
||||
localStorage.removeItem('AEVT')
|
||||
localStorage.removeItem('ATLT')
|
||||
localStorage.removeItem('extendTalent')
|
||||
const unique = localStorage.getItem('uniqueWaTaShi')
|
||||
if (unique) localStorage.setItem('unique', unique)
|
||||
localStorage.removeItem('uniqueWaTaShi')
|
||||
}
|
||||
localStorage.setItem('version', '3.0.0')
|
||||
}
|
||||
|
||||
export async function get(key: string) {
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
|
||||
export async function set(key: string, value: string) {
|
||||
localStorage.setItem(key, value)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--base-text-color: light-dark(#121314, #f9fafb);
|
||||
--base-background-color: light-dark(#f7f7f7, #121314);
|
||||
--base-shadow: light-dark(#00000010, #23456710);
|
||||
|
||||
--color-grade-0: light-dark(#4e5052, #f9fafb);
|
||||
--color-grade-1: light-dark(#4aa8da, #afe3ff);
|
||||
--color-grade-2: light-dark(#b03fe4, #e09eff);
|
||||
--color-grade-3: light-dark(#ffaf2d, #ffe48d);
|
||||
|
||||
--color-success: light-dark(#61b700, #b9ff69);
|
||||
--color-info: light-dark(#454ad3, #b5b7ff);
|
||||
--color-error: light-dark(#e42b2b, #ff6969);
|
||||
--color-primary: var(--color-success);
|
||||
--color-secondary: var(--color-info);
|
||||
}
|
||||
|
||||
.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); }
|
||||
@@ -0,0 +1,134 @@
|
||||
:root {
|
||||
--ratio: 1;
|
||||
--ratio-font: 1;
|
||||
color: var(--base-text-color);
|
||||
}
|
||||
|
||||
html { transition: all 0.2s ease-in-out; }
|
||||
html[data-theme='light'] { color-scheme: light; }
|
||||
html[data-theme='dark'] { color-scheme: dark; }
|
||||
.hide-scrollbar,
|
||||
html {
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
body {
|
||||
min-height: calc(100dvh - 2rem);
|
||||
width: calc(100dvw - 2rem);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
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",
|
||||
"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,
|
||||
"Noto Sans Mono CJK SC", "Noto Sans Mono", "Noto Code",
|
||||
ui-monospace, monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button {
|
||||
--style-color: var(--base-text-color);
|
||||
--style-background: var(--base-background-color);
|
||||
--border-color: var(--style-color);
|
||||
--background-color: var(--style-background);
|
||||
--text-color: var(--style-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-color) 0.0625rem solid;
|
||||
background: var(--background-color);
|
||||
font-size: calc(1.2rem * var(--ratio-font));
|
||||
font-weight: bold;
|
||||
padding: 0 1rem;
|
||||
height: calc(2.5rem * var(--ratio));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--style-color);
|
||||
color: var(--style-background);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--style-color);
|
||||
color: var(--style-background);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.primary { --style-color: var(--color-primary); }
|
||||
&.secondary { --style-color: var(--color-secondary); }
|
||||
&.info { --style-color: var(--color-info); }
|
||||
&.error { --style-color: var(--color-error); }
|
||||
&.focus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(3.5rem * var(--ratio));
|
||||
font-size: calc(1.6rem * var(--ratio-font));
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: calc(1rem * var(--ratio-font));
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: var(--base-background-color);
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
appearance: none;
|
||||
color: var(--base-text-color);
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:focus { outline: none; }
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0; /* 移除某些浏览器下的默认留白 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) { :root { font-size: 2.88vw; } }
|
||||
@media screen and (orientation: portrait) and (pointer: coarse) {
|
||||
:root {
|
||||
--ratio: 1.5;
|
||||
--ratio-font: 1.2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.achv-toast-container {
|
||||
position: fixed;
|
||||
width: min(calc(100dvw - 2rem), 48rem);
|
||||
top: 6rem;
|
||||
left: 1rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
.toast-item {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&.visible {
|
||||
animation: achv-slide-in 0.2s ease-out forwards;
|
||||
}
|
||||
&.hidden {
|
||||
animation: achv-fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes achv-slide-in {
|
||||
0% { opacity: 0; transform: translateX(-100%); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes achv-slide-out {
|
||||
0% { opacity: 1; transform: translateX(0); }
|
||||
100% { opacity: 0; transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
@keyframes achv-fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes achv-fade-out {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useToaster, toast } from 'react-hot-toast/headless'
|
||||
import Achievement from '@/components/Achievement'
|
||||
import './Achv.css'
|
||||
|
||||
export function ToastAchvContainer() {
|
||||
const { toasts, handlers } = useToaster({ duration: 4000 }, 'achv-toast')
|
||||
const { startPause, endPause, calculateOffset, updateHeight } = handlers
|
||||
return (
|
||||
<div
|
||||
className="achv-toast-container"
|
||||
onMouseEnter={startPause}
|
||||
onMouseLeave={endPause}
|
||||
>
|
||||
{toasts.map(toast => {
|
||||
const offset = calculateOffset(toast, {
|
||||
reverseOrder: true,
|
||||
gutter: 8,
|
||||
})
|
||||
return (
|
||||
<div
|
||||
className={`toast-item ${toast.visible ? 'visible' : 'hidden'}`}
|
||||
key={toast.id}
|
||||
ref={el => {
|
||||
if (el && typeof toast.height !== 'number') {
|
||||
const height = el.getBoundingClientRect().height
|
||||
updateHeight(toast.id, height)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
top: `${offset}px`,
|
||||
}}
|
||||
>
|
||||
{typeof toast.message == 'function'
|
||||
? toast.message(toast)
|
||||
: toast.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ToastAchvContainer
|
||||
|
||||
export function toastAchvs(achievements: number[]) {
|
||||
if (achievements.length === 0) return
|
||||
for (const id of achievements) {
|
||||
toast(<Achievement id={id} />, { id: '' + id, toasterId: 'achv-toast' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.msg-toast-container {
|
||||
position: fixed;
|
||||
width: min(calc(100dvw - 2rem), 48rem);
|
||||
top: 6rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
.toast-item {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&.visible {
|
||||
animation: msg-slide-in 0.2s ease-out forwards;
|
||||
}
|
||||
&.hidden {
|
||||
animation: msg-fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes msg-slide-in {
|
||||
0% { opacity: 0; transform: translateY(-100%); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes msg-slide-out {
|
||||
0% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-100%); }
|
||||
}
|
||||
|
||||
@keyframes msg-fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes msg-fade-out {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useToaster, toast } from 'react-hot-toast/headless'
|
||||
import Message from '@/components/Message'
|
||||
import './Msg.css'
|
||||
|
||||
export function ToastMsgContainer() {
|
||||
const { toasts, handlers } = useToaster({ duration: 1500 }, 'msg-toast')
|
||||
const { startPause, endPause, calculateOffset, updateHeight } = handlers
|
||||
return (
|
||||
<div
|
||||
className="msg-toast-container"
|
||||
onMouseEnter={startPause}
|
||||
onMouseLeave={endPause}
|
||||
>
|
||||
{toasts.map(toast => {
|
||||
const offset = calculateOffset(toast, {
|
||||
reverseOrder: true,
|
||||
gutter: 8,
|
||||
})
|
||||
return (
|
||||
<div
|
||||
className={`toast-item ${toast.visible ? 'visible' : 'hidden'}`}
|
||||
key={toast.id}
|
||||
ref={el => {
|
||||
if (el && typeof toast.height !== 'number') {
|
||||
const height = el.getBoundingClientRect().height
|
||||
updateHeight(toast.id, height)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
top: `${offset}px`,
|
||||
}}
|
||||
>
|
||||
{typeof toast.message == 'function'
|
||||
? toast.message(toast)
|
||||
: toast.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ToastMsgContainer
|
||||
|
||||
export function toastMsg(message: string, key: string) {
|
||||
toast(<Message message={message} />, { id: key, toasterId: 'msg-toast' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from './Msg'
|
||||
export * from './Achv'
|
||||
import ToastAchvContainer from './Achv'
|
||||
import ToastMsgContainer from './Msg'
|
||||
|
||||
export const ToastContainer = () => (
|
||||
<>
|
||||
<ToastAchvContainer />
|
||||
<ToastMsgContainer />
|
||||
</>
|
||||
)
|
||||
|
||||
export default ToastContainer
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["react", "vite/client"],
|
||||
"jsxImportSource": "react",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
},
|
||||
"include": ["**/*"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const dataSplitRule = {
|
||||
test: /[\\/]packages[\\/]data[\\/]|@remake\/data/,
|
||||
priority: 40,
|
||||
name(id: string) {
|
||||
const name = id.split(/[\/\\]/).pop()!
|
||||
const baseName = name.substring(0, name.lastIndexOf('.'))
|
||||
if (baseName && baseName !== 'index') return `data-${baseName}`
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { tsconfigPaths: true },
|
||||
define: { 'import.meta.env.VITE_CHANNEL': '"bili"' },
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist/bili/remake',
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
minSize: 1024,
|
||||
groups: [dataSplitRule],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const dataSplitRule = {
|
||||
test: /[\\/]packages[\\/]data[\\/]|@remake\/data/,
|
||||
priority: 40,
|
||||
name(id: string) {
|
||||
const name = id.split(/[\/\\]/).pop()!
|
||||
const baseName = name.substring(0, name.lastIndexOf('.'))
|
||||
if (baseName && baseName !== 'index') return `data-${baseName}`
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { tsconfigPaths: true },
|
||||
base: './',
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
minSize: 1024,
|
||||
groups: [dataSplitRule],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 70 KiB |