16 Commits
Author SHA1 Message Date
VickScarlet 851e2351bc Deploying to gh-pages from @ VickScarlet/remake@2d53a6ca33 🚀 2026-08-16 12:06:40 +00:00
VickScarlet 7962ea5d52 Deploying to gh-pages from @ VickScarlet/lifeRestart@943fa8da23 🚀 2026-06-29 07:14:51 +00:00
VickScarlet 9e27a3c562 Deploying to gh-pages from @ VickScarlet/lifeRestart@03bcb19942 🚀 2026-06-29 07:00:16 +00:00
VickScarlet 3b37b960fa Deploying to gh-pages from @ VickScarlet/lifeRestart@f12a147093 🚀 2026-03-30 09:50:02 +00:00
VickScarlet cbe5a5a8ca Deploying to gh-pages from @ VickScarlet/lifeRestart@ddc87ebf40 🚀 2026-01-26 09:50:51 +00:00
VickScarlet 0309eecee4 Deploying to gh-pages from @ VickScarlet/lifeRestart@9088ab0eb1 🚀 2026-01-26 06:48:14 +00:00
VickScarlet f27bf5bd00 Deploying to gh-pages from @ VickScarlet/lifeRestart@39388c6eaf 🚀 2025-09-24 09:46:37 +00:00
VickScarlet 3039fe3e92 Deploying to gh-pages from @ VickScarlet/lifeRestart@aee4fcf164 🚀 2025-05-17 13:27:27 +00:00
VickScarlet 5425fbc50d Deploying to gh-pages from @ VickScarlet/lifeRestart@5c0fe56915 🚀 2025-05-17 13:17:45 +00:00
VickScarlet c941be7e28 Deploying to gh-pages from @ VickScarlet/lifeRestart@b82da6c9a7 🚀 2025-04-06 12:39:20 +00:00
VickScarlet 516156d8e7 Deploying to gh-pages from @ VickScarlet/lifeRestart@b1ffae9eaf 🚀 2025-04-06 11:39:24 +00:00
VickScarlet aeedbfbe47 Deploying to gh-pages from @ VickScarlet/lifeRestart@fee986432d 🚀 2025-04-05 10:37:25 +00:00
VickScarlet e2c97807ab Deploying to gh-pages from @ VickScarlet/lifeRestart@3f58e3deae 🚀 2025-04-05 09:58:31 +00:00
VickScarlet 633f379c45 Deploying to gh-pages from @ VickScarlet/lifeRestart@788c864e71 🚀 2025-04-05 09:47:58 +00:00
VickScarlet 6c1e71a964 Deploying to gh-pages from @ VickScarlet/lifeRestart@8f46e286dd 🚀 2025-04-05 09:40:46 +00:00
VickScarlet e3d1b38c73 Initial gh-pages commit 2025-04-05 09:40:46 +00:00
145 changed files with 46 additions and 12400 deletions
-3
View File
@@ -1,3 +0,0 @@
paths-ignore:
- 'public/libs/**'
- 'tests'
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
-37
View File
@@ -1,37 +0,0 @@
name: Build and Deploy
on:
push:
branches: [main]
permissions:
contents: write
jobs:
build-and-deploy:
concurrency: ci-${{ github.ref }}
runs-on: ubuntu-latest
steps:
- name: 🛎️ Checkout
uses: actions/checkout@v4
- name: 🛠️ Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: 🛠️ Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: 📦 Install Dependencies
run: pnpm install
- 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: apps/web/dist # The folder the action should deploy.
-113
View File
@@ -1,113 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
utils/xlsxTransform-*
/.idea
__localStorage.json
template/public
public/data
-24
View File
@@ -1,24 +0,0 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: 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",
"name": "test",
"program": "${workspaceFolder}/test",
"skipFiles": [
"<node_internals>/**",
"**/node_modumes/**"
]
},
]
}
-3
View File
@@ -1,3 +0,0 @@
{
"js/ts.experimental.useTsgo": true
}
+1
View File
@@ -0,0 +1 @@
liferestart.syaro.io
-21
View File
@@ -1,21 +0,0 @@
MIT License
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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-19
View File
@@ -1,19 +0,0 @@
# 人生重开模拟器
<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) | 简体中文
## 开发环境
包管理: `pnpm`
运行时: `bun`
## 使用
### 开发
```bash
pnpm build:data
pnpm dev
```
-19
View File
@@ -1,19 +0,0 @@
# 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)
## Environment
Package Manager: `pnpm`
Runtime: `bun`
## Usage
### Dev
```bash
pnpm build:data
pnpm dev
```
-870
View File
@@ -1,870 +0,0 @@
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import { readFile } from 'fs/promises'
import Life from '../srco/modules/life.js'
import $lang from '../srco/i18n/zh-cn.js'
globalThis.$lang = $lang
globalThis.json = async fileName =>
JSON.parse(await readFile(`${__dirname}/../public/data/${fileName}.json`))
globalThis.$$eventMap = new Map()
globalThis.$$event = (tag, data) => {
const listener = $$eventMap.get(tag)
if (listener) listener.forEach(fn => fn(data))
}
globalThis.$$on = (tag, fn) => {
let listener = $$eventMap.get(tag)
if (!listener) {
listener = new Set()
$$eventMap.set(tag, listener)
}
listener.add(fn)
}
globalThis.$$off = (tag, fn) => {
const listener = $$eventMap.get(tag)
if (listener) listener.delete(fn)
}
class App {
constructor() {
this.#life.config({
defaultPropertyPoints: 20, // default number of points for a property
talentSelectLimit: 3, // max number of talents that can be selected
propertyAllocateLimit: [0, 10], // scoop of properties that can be allocated,
defaultPropertys: { SPR: 5 }, // default properties
talentConfig: {
// config for talent
talentPullCount: 10, // number of talents to pull from the talent pool
talentRate: { 1: 100, 2: 10, 3: 1, total: 1000 }, // rate of talent pull
additions: {
TMS: [
[10, { 2: 1 }],
[30, { 2: 2 }],
[50, { 2: 3 }],
[70, { 2: 4 }],
[100, { 2: 5 }],
],
CACHV: [
[10, { 2: 1 }],
[30, { 2: 2 }],
[50, { 2: 3 }],
[70, { 2: 4 }],
[100, { 2: 5 }],
],
},
},
propertyConfig: {
// config for property
judge: {
// type: [min, grade, judge]
RTLT: [
[0, 0],
[0.3, 1],
[0.6, 2],
[0.9, 3],
],
REVT: [
[0, 0],
[0.2, 1],
[0.4, 2],
[0.6, 3],
],
TMS: [
[0, 0, 'UI_Remake_Times_Judge_Level_0'],
[10, 1, 'UI_Remake_Times_Judge_Level_1'],
[30, 1, 'UI_Remake_Times_Judge_Level_2'],
[50, 2, 'UI_Remake_Times_Judge_Level_3'],
[70, 2, 'UI_Remake_Times_Judge_Level_4'],
[100, 3, 'UI_Remake_Times_Judge_Level_5'],
],
CACHV: [
[0, 0, 'UI_Achievement_Count_Judge_Level_0'],
[10, 1, 'UI_Achievement_Count_Judge_Level_1'],
[30, 1, 'UI_Achievement_Count_Judge_Level_2'],
[50, 2, 'UI_Achievement_Count_Judge_Level_3'],
[70, 2, 'UI_Achievement_Count_Judge_Level_4'],
[100, 3, 'UI_Achievement_Count_Judge_Level_5'],
],
HCHR: [
[0, 0, 'UI_Judge_Level_0'],
[1, 0, 'UI_Judge_Level_1'],
[2, 0, 'UI_Judge_Level_2'],
[4, 0, 'UI_Judge_Level_3'],
[7, 1, 'UI_Judge_Level_4'],
[9, 2, 'UI_Judge_Level_5'],
[11, 3, 'UI_Judge_Level_6'],
],
HMNY: [
[0, 0, 'UI_Judge_Level_0'],
[1, 0, 'UI_Judge_Level_1'],
[2, 0, 'UI_Judge_Level_2'],
[4, 0, 'UI_Judge_Level_3'],
[7, 1, 'UI_Judge_Level_4'],
[9, 2, 'UI_Judge_Level_5'],
[11, 3, 'UI_Judge_Level_6'],
],
HSPR: [
[0, 0, 'UI_Spirit_Judge_Level_0'],
[1, 0, 'UI_Spirit_Judge_Level_1'],
[2, 0, 'UI_Spirit_Judge_Level_2'],
[4, 0, 'UI_Spirit_Judge_Level_3'],
[7, 1, 'UI_Spirit_Judge_Level_4'],
[9, 2, 'UI_Spirit_Judge_Level_5'],
[11, 3, 'UI_Spirit_Judge_Level_6'],
],
HINT: [
[0, 0, 'UI_Judge_Level_0'],
[1, 0, 'UI_Judge_Level_1'],
[2, 0, 'UI_Judge_Level_2'],
[4, 0, 'UI_Judge_Level_3'],
[7, 1, 'UI_Judge_Level_4'],
[9, 2, 'UI_Judge_Level_5'],
[11, 3, 'UI_Judge_Level_6'],
[21, 3, 'UI_Intelligence_Judge_Level_7'],
[131, 3, 'UI_Intelligence_Judge_Level_8'],
[501, 3, 'UI_Intelligence_Judge_Level_9'],
],
HSTR: [
[0, 0, 'UI_Judge_Level_0'],
[1, 0, 'UI_Judge_Level_1'],
[2, 0, 'UI_Judge_Level_2'],
[4, 0, 'UI_Judge_Level_3'],
[7, 1, 'UI_Judge_Level_4'],
[9, 2, 'UI_Judge_Level_5'],
[11, 3, 'UI_Judge_Level_6'],
[21, 3, 'UI_Strength_Judge_Level_7'],
[101, 3, 'UI_Strength_Judge_Level_8'],
[401, 3, 'UI_Strength_Judge_Level_9'],
[1001, 3, 'UI_Strength_Judge_Level_10'],
[2001, 3, 'UI_Strength_Judge_Level_11'],
],
HAGE: [
[0, 0, 'UI_AGE_Judge_Level_0'],
[1, 0, 'UI_AGE_Judge_Level_1'],
[10, 0, 'UI_AGE_Judge_Level_2'],
[18, 0, 'UI_AGE_Judge_Level_3'],
[40, 0, 'UI_AGE_Judge_Level_4'],
[60, 1, 'UI_AGE_Judge_Level_5'],
[70, 1, 'UI_AGE_Judge_Level_6'],
[80, 2, 'UI_AGE_Judge_Level_7'],
[90, 2, 'UI_AGE_Judge_Level_8'],
[95, 3, 'UI_AGE_Judge_Level_9'],
[100, 3, 'UI_AGE_Judge_Level_10'],
[500, 3, 'UI_AGE_Judge_Level_11'],
],
SUM: [
[0, 0, 'UI_Judge_Level_0'],
[41, 0, 'UI_Judge_Level_1'],
[50, 0, 'UI_Judge_Level_2'],
[60, 0, 'UI_Judge_Level_3'],
[80, 1, 'UI_Judge_Level_4'],
[100, 2, 'UI_Judge_Level_5'],
[110, 3, 'UI_Judge_Level_6'],
[120, 3, 'UI_Judge_Level_7'],
],
},
},
characterConfig: {
// config for character
characterPullCount: 3,
rateableKnife: 10,
propertyWeight: [
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[4, 5],
[5, 6],
[6, 5],
[7, 4],
[8, 3],
[9, 2],
[10, 1],
],
talentWeight: [
[1, 1],
[2, 2],
[3, 3],
[4, 2],
[5, 1],
],
},
})
}
Steps = {
TALENT: 'talent',
PROPERTY: 'property',
TRAJECTORY: 'trajectory',
SUMMARY: 'summary',
}
#language = 'zh-cn'
#step = this.Steps.SUMMARY
#life = new Life()
#talentSelected = new Set()
#talentExtend = new Set()
#auto
#isEnd
#propertyAllocation
#output
#exit
#interval
#style = {
warn: ['\x1B[93m', '\x1B[39m'], // Bright Yellow
grade1: ['\x1B[94m', '\x1B[39m'], // Bright Blue
grade2: ['\x1B[95m', '\x1B[39m'], // Bright Magenta
grade3: ['\x1B[93m', '\x1B[39m'], // Bright Yellow
grade1b: ['\x1B[94m\x1B[7m', '\x1B[0m'], // Bright Blue BG
grade2b: ['\x1B[95m\x1B[7m', '\x1B[0m'], // Bright Magenta BG
grade3b: ['\x1B[93m\x1B[7m', '\x1B[0m'], // Bright Yellow BG
}
#randomTalents
style(type, str) {
const style = this.#style[type]
if (!style) return str
return `${style[0]}${str}${style[1]}`
}
async initial() {
this.output('Now Loading...')
this.#talentExtend = localStorage.talentExtend
await this.#life.initial(
dataSet => json(`${this.#language}/${dataSet}`),
json,
)
this.output(
`\rLoading Complete.
人生重开模拟器
这垃圾人生一秒也不想待了
键入 \x1B[4m/remake\x1B[24m 开始游戏
键入 \x1B[4m/help\x1B[24m 获取帮助`,
true,
)
$$on('achievement', ({ name }) =>
this.output(`
-------------------------
解锁成就【${name}
-------------------------
`),
)
}
io(input, output, exit) {
this.#output = output
this.#exit = exit
input(command => {
const ret = this.repl(command)
if (!ret) return
if (typeof ret == 'string') return this.output(ret, true)
if (Array.isArray(ret)) return this.output(...ret)
const { message, isRepl } = ret
return this.output(message, isRepl)
})
}
output(data, isRepl) {
if (!this.#output) return
this.#output(data, isRepl)
}
exit(code) {
if (this.#exit) this.#exit(code)
process.exit(code)
}
repl(command) {
command = command.split(/\s+/)
switch (command.shift()) {
case 'r':
case 'remake':
case '/remake':
return this.remake()
case 's':
case 'select':
case '/select':
return this.select(...command)
case 'u':
case 'unselect':
case '/unselect':
return this.unselect(...command)
case 'n':
case 'next':
case '/next':
case '':
return this.next(true)
case 'a':
case 'alloc':
case 'allocate':
case 'attrib':
case 'attribute':
case '/alloc':
case '/allocate':
case '/attrib':
case '/attribute':
return this.attrib(...command)
case 'rd':
case 'random':
case '/random':
return this.random()
case 'at':
case 'auto':
case '/auto':
return this.auto(...command)
case 'x':
case 'exit':
case '/exit':
return this.exit(0)
case '?':
case 'h':
case 'help':
case '/?':
case '/h':
case '/help':
default:
return this.help(...command)
}
}
help(key) {
switch (key) {
case 'x':
case 'exit':
case '/exit':
return `退出
x, exit, /exit 命令同等效果`
case 'r':
case 'remake':
case '/remake':
return `重开
r, remake, /remake 命令同等效果`
case 's':
case 'select':
case '/select':
return `选择
s, select, /select 命令同等效果
Example: /select 1 2 3 意味着选择 1 2 3 三个天赋
/select <id1> [id2] [id3]
参数解释 <id1> 通常来说要指定至少一个id
虽然不指定也可以
[id2]
[id3] 可以不指定`
case 'u':
case 'unselect':
case '/unselect':
return `取消选择
u, unselect,
/unselect 命令同等效果
Example: /unselect 1 2 3
意味着取消选择 1 2 3 三个天赋
参数解释 /unselect <id1> [id2] [id3]
<id1> 通常来说要指定至少一个id
虽然不指定也可以
[id2]
[id3] 可以不指定`
case 'a':
case 'alloc':
case 'allocate':
case 'attrib':
case 'attribute':
case '/alloc':
case '/allocate':
case '/attrib':
case '/attribute':
return `分配或查看属性点
a, alloc, allocate, attrib, attribute
/alloc, /allocate, /attrib, /attribute 命令同等效果
Example: /attribute
/allocate STR 1
/allocate INT -3
/allocate CHR +5
效果 在属性分配时分配属性点
在人生的过程中查看当前属性点
参数解释 /allocate <TAG> <[+/-]value>
<TAG> 表示要分配的属性标签
可选有
CHR, chr, c, C 表示颜值
INT, int, i, I 表示智力
STR, str, s, S 表示体质
MNY, mny, m, M 表示家境
必填
<[+/-]value>
表示属性的调整
其中
+ 表示在当前基础上增加
- 表示在当前基础上减少
无符号表示直接设置为此值
必填`
case 'n':
case 'next':
case '/next':
return `继续
n, next, /next 命令同等效果
效果 通常用于各步骤结束后
例如: 选择天赋后
分配属性后
每个年龄事件后
总评后
继承天赋后`
case 'at':
case 'auto':
case '/auto':
return `自动播放
at, auto, /auto 命令同等效果
效果 用于人生的过程中
每个年龄会自动下一年
播放速度 1 秒 1 年`
case '?':
case 'h':
case 'help':
case '/?':
case '/h':
case '/help':
return `显示帮助
, h, help
/?, /h, /help 命令同等效果
Example: /help
/help /select
参数解释 /help [command]
[command] 要详细显示帮助的命令
可以不填`
}
return `Help ---
命令 说明 示例
x
exit
/exit 退出 /exit
r
remake
/remake 重开 /remake
s
select
/select 选择天赋 /select <id1> [id2] [id3]
u
unselect
/unselect 取消选择 /unselect <id1> [id2] [id3]
a
alloc
allocate
attrib
attribute
/alloc
/allocate
/attrib
/attribute 分配或查看属性点 /allocate <TAG> <[+/-]value>
n
next
/next 继续 /next
at
auto
/auto 自动播放 /auto
?
h
help
/?
/h
/help 显示帮助 /help [command]`
}
auto(arg) {
this.#auto = arg != 'off'
return this.next(true)
}
remake() {
if (this.#talentExtend) {
this.#life.talentExtend(this.#talentExtend)
dumpLocalStorage()
this.#talentExtend = null
}
this.#isEnd = false
this.#talentSelected.clear()
this.#propertyAllocation = {
CHR: 0,
INT: 0,
STR: 0,
MNY: 0,
SPR: 5,
}
this.#step = this.Steps.TALENT
this.#randomTalents = this.#life.talentRandom()
return this.list()
}
select(...select) {
switch (this.#step) {
case this.Steps.TALENT:
return this.talentSelect(...select)
case this.Steps.SUMMARY:
return this.talentExtend(...select)
}
}
unselect(...select) {
switch (this.#step) {
case this.Steps.TALENT:
return this.talentUnSelect(...select)
case this.Steps.SUMMARY:
return this.talentExtendCancle(...select)
}
}
talentSelect(...select) {
const warn = str => `${this.list()}\n${this.style('warn', str)}`
for (const number of select) {
const s = this.#randomTalents[number]
if (!s) return warn(`${number} 为未知天赋`)
if (this.#talentSelected.has(s)) continue
if (this.#talentSelected.size == 3)
return warn(
'你只能选3个天赋。请使用 \x1B[4m/unselect\x1B[24m 取消选择你不想要的天赋',
)
const exclusive = this.#life.exclude(
Array.from(this.#talentSelected).map(({ id }) => id),
s.id,
)
if (exclusive != null)
for (const { name, id } of this.#talentSelected)
if (id == exclusive)
return warn(
`天赋【${s.name}】与已选择的天赋【${name}】冲突`,
)
this.#talentSelected.add(s)
}
return this.list()
}
talentUnSelect(...select) {
for (const number of select) {
const s = this.#randomTalents[number]
if (this.#talentSelected.has(s)) this.#talentSelected.delete(s)
}
return this.list()
}
talentExtend(select) {
const warn = str => `${this.list()}\n${this.style('warn', str)}`
const list = Array.from(this.#talentSelected)
const s = list[select]
if (!s) return warn(`${select} 为未知天赋`)
this.#talentExtend = s.id
return this.list()
}
talentExtendCancle() {
this.#talentExtend = null
return this.list()
}
list() {
let description, list, check
switch (this.#step) {
case this.Steps.TALENT:
description = '🎉 请选择(\x1B[4m/select\x1B[24m3 个天赋'
list = this.#randomTalents
check = talent => this.#talentSelected.has(talent)
break
case this.Steps.SUMMARY:
description =
'🎉 你可以选(\x1B[4m/select\x1B[24m)一个天赋继承'
list = Array.from(this.#talentSelected)
check = ({ id }) => this.#talentExtend == id
break
}
if (!list) return ''
return [
description,
list.map((talent, i) =>
this.style(
`grade${talent.grade}b`,
`${check(talent) ? '√' : ' '} ${i} ${talent.name}${
talent.description
}`,
),
),
]
.flat()
.join('\n')
}
next(enter) {
const warn = (a, b) =>
`${a}\n${this.style('warn', this.style('warn', b))}`
switch (this.#step) {
case this.Steps.TALENT:
if (this.#talentSelected.size != 3)
return warn(this.list(), `请选择 3 个天赋`)
this.#step = this.Steps.PROPERTY
this.#life.remake(
Array.from(this.#talentSelected).map(({ id }) => id),
)
this.#propertyAllocation.total = this.#life.getPropertyPoints()
return this.prop()
case this.Steps.PROPERTY:
const less = this.less()
if (less > 0)
return warn(this.prop(), `你还有 ${less} 属性点没有分配完`)
this.#step = this.Steps.TRAJECTORY
delete this.#propertyAllocation.total
this.#life.start(this.#propertyAllocation)
return this.trajectory(enter)
case this.Steps.TRAJECTORY:
if (!this.#isEnd) return this.trajectory(enter)
this.#step = this.Steps.SUMMARY
return `${this.summary()}\n\n${this.list()}`
case this.Steps.SUMMARY:
return this.remake()
}
}
trajectory(enter) {
if (enter) {
if (this.#interval) {
clearInterval(this.#interval)
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,
)
return
}
}
const trajectory = this.#life.next()
const { age, content, isEnd } = trajectory
if (isEnd) this.#isEnd = true
return `${age}岁:\t${content
.map(({ type, description, grade, name, postEvent }) => {
switch (type) {
case 'TLT':
return `${name}${description}`
case 'EVT':
return (
description + (postEvent ? `\n\t${postEvent}` : '')
)
}
})
.join('\n\t')}`
}
prop() {
const { CHR, INT, STR, MNY } = this.#propertyAllocation
return `🎉 属性分配
请使用 \x1B[4m/alloc\x1B[24m <TAG> <value> 分配属性
剩余点数 ${this.less()}
属性(TAG) 当前值
颜值(CHR) ${CHR}
智力(INT) ${INT}
体质(STR) ${STR}
家境(MNY) ${MNY}
`
}
less() {
const { total, CHR, INT, STR, MNY } = this.#propertyAllocation
return total - CHR - INT - STR - MNY
}
attrib(tag, value) {
switch (this.#step) {
case this.Steps.PROPERTY:
return this.alloc(tag, value)
case this.Steps.TRAJECTORY:
return this.showProperty()
default:
return undefined
}
}
showProperty() {
let property = this.#life.getLastRecord()
return `当前属性
属性(TAG) 当前值
颜值(CHR) ${property.CHR}
智力(INT) ${property.INT}
体质(STR) ${property.STR}
家境(MNY) ${property.MNY}
快乐(SPR) ${property.SPR}`
}
alloc(tag, value) {
const warn = str => `${this.prop()}\n${this.style('warn', str)}`
if (!value) return warn('⚠ 分配的数值没有给定')
const isSet = !(value[0] == '-' || value[0] == '+')
value = Number(value)
if (isNaN(value)) return warn('⚠ 分配的数值不正确')
switch (tag) {
case 'c':
case 'chr':
case 'C':
tag = 'CHR'
break
case 'i':
case 'int':
case 'I':
tag = 'INT'
break
case 's':
case 'S':
case 'str':
tag = 'STR'
break
case 'm':
case 'M':
case 'mny':
tag = 'MNY'
break
}
switch (tag) {
case 'CHR':
case 'INT':
case 'STR':
case 'MNY':
break
default:
return warn('⚠ 未知的tag')
}
if (isSet) value = value - this.#propertyAllocation[tag]
const tempLess = this.less() - value
const tempSet = this.#propertyAllocation[tag] + value
if (tempLess < 0) return warn('⚠ 你没有更多的点数可以分配了')
if (tempLess > this.#propertyAllocation.total || tempSet < 0)
return warn('⚠ 不能分配负数属性')
if (tempSet > 10) return warn('⚠ 单项属性最高分配10点')
this.#propertyAllocation[tag] += value
return this.prop()
}
random() {
switch (this.#step) {
case this.Steps.TALENT:
this.#talentSelected.clear()
Array.from(this.#randomTalents)
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.forEach(talent => {
this.#talentSelected.add(talent)
})
return this.list()
case this.Steps.PROPERTY:
let t = this.#propertyAllocation.total
const arr = [10, 10, 10, 10]
while (t > 0) {
const sub =
Math.round(Math.random() * (Math.min(t, 10) - 1)) + 1
while (true) {
const select = Math.floor(Math.random() * 4) % 4
if (arr[select] - sub < 0) continue
arr[select] -= sub
t -= sub
break
}
}
this.#propertyAllocation.CHR = 10 - arr[0]
this.#propertyAllocation.INT = 10 - arr[1]
this.#propertyAllocation.STR = 10 - arr[2]
this.#propertyAllocation.MNY = 10 - arr[3]
return this.prop()
case this.Steps.SUMMARY:
this.#talentExtend = Array.from(this.#talentSelected).sort(
() => Math.random() - 0.5,
)[0].id
return this.list()
}
}
summary() {
const summaryData = this.#life.summary
const format = (name, type) => {
const { judge, grade, value } = summaryData[type]
return this.style(
`grade${grade}b`,
`${name}${value} ${$lang[judge]}`,
)
}
return [
'🎉 总评',
format('颜值', this.#life.PropertyTypes.HCHR),
format('智力', this.#life.PropertyTypes.HINT),
format('体质', this.#life.PropertyTypes.HSTR),
format('家境', this.#life.PropertyTypes.HMNY),
format('快乐', this.#life.PropertyTypes.HSPR),
format('享年', this.#life.PropertyTypes.HAGE),
format('总评', this.#life.PropertyTypes.SUM),
].join('\n')
}
}
export default App
-44
View File
@@ -1,44 +0,0 @@
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import App from './app.js'
import { readFile, writeFile } from 'fs/promises'
async function main() {
try {
globalThis.localStorage = JSON.parse(
await readFile(__dirname + '/__localStorage.json')
)
} catch (e) {
globalThis.localStorage = {}
}
localStorage.getItem = key =>
localStorage[key] === void 0 ? null : localStorage[key]
localStorage.setItem = (key, value) => (localStorage[key] = value)
globalThis.dumpLocalStorage = async () =>
await writeFile(
__dirname + '/__localStorage.json',
JSON.stringify(global.localStorage)
)
const app = new App()
app.io(
repl => process.stdin.on('data', data => repl(data.toString().trim())),
(data, isRepl) => process.stdout.write(`${data}${isRepl ? '\n>' : ''}`),
code => process.exit(code)
)
await app.initial()
}
main()
// process.stdin.setRawMode(true);
// process.openStdin().on('keypress', function (chunk, key) {
// process.stdout.write('Get Chunk: ' + chunk + '\n');
// if (key && key.ctrl && key.name == 'c') process.exit();
// });
-15
View File
@@ -1,15 +0,0 @@
{
"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:*"
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
-15
View File
@@ -1,15 +0,0 @@
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,
])
-34
View File
@@ -1,34 +0,0 @@
{
"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"
}
}
-64
View File
@@ -1,64 +0,0 @@
.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; }
}
-46
View File
@@ -1,46 +0,0 @@
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
-31
View File
@@ -1,31 +0,0 @@
.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); }
}
}
-33
View File
@@ -1,33 +0,0 @@
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
-12
View File
@@ -1,12 +0,0 @@
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); }
}
-23
View File
@@ -1,23 +0,0 @@
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
-10
View File
@@ -1,10 +0,0 @@
.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;
}
-10
View File
@@ -1,10 +0,0 @@
import './Message.css'
export interface MsgToastProps {
message: React.ReactNode
}
export function MsgToast({ message }: MsgToastProps) {
return <div className="message-item">{message}</div>
}
export default MsgToast
-50
View File
@@ -1,50 +0,0 @@
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;
}
}
}
-28
View File
@@ -1,28 +0,0 @@
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
-62
View File
@@ -1,62 +0,0 @@
.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); }
}
}
-20
View File
@@ -1,20 +0,0 @@
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
-37
View File
@@ -1,37 +0,0 @@
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
-37
View File
@@ -1,37 +0,0 @@
.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); }
}
-85
View File
@@ -1,85 +0,0 @@
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
-151
View File
@@ -1,151 +0,0 @@
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
-119
View File
@@ -1,119 +0,0 @@
.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;
}
}
}
}
}
}
-86
View File
@@ -1,86 +0,0 @@
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
-123
View File
@@ -1,123 +0,0 @@
.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%; }
}
}
-134
View File
@@ -1,134 +0,0 @@
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
-91
View File
@@ -1,91 +0,0 @@
.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 }
}
}
}
}
-132
View File
@@ -1,132 +0,0 @@
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
-34
View File
@@ -1,34 +0,0 @@
.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; }
}
}
}
-41
View File
@@ -1,41 +0,0 @@
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>
)
}
-23
View File
@@ -1,23 +0,0 @@
.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: ']'; }
}
}
}
}
-26
View File
@@ -1,26 +0,0 @@
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
-22
View File
@@ -1,22 +0,0 @@
.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%;
}
}
}
}
-52
View File
@@ -1,52 +0,0 @@
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
-165
View File
@@ -1,165 +0,0 @@
.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); }
}
-203
View File
@@ -1,203 +0,0 @@
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
-43
View File
@@ -1,43 +0,0 @@
.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%;
}
}
-82
View File
@@ -1,82 +0,0 @@
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>
)
}
-95
View File
@@ -1,95 +0,0 @@
.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;}
}
}
}
-47
View File
@@ -1,47 +0,0 @@
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
-31
View File
@@ -1,31 +0,0 @@
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]
}
-46
View File
@@ -1,46 +0,0 @@
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])
}
-63
View File
@@ -1,63 +0,0 @@
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
}
-13
View File
@@ -1,13 +0,0 @@
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>,
)
-15
View File
@@ -1,15 +0,0 @@
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)
})
})
-23
View File
@@ -1,23 +0,0 @@
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
-39
View File
@@ -1,39 +0,0 @@
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
}
-22
View File
@@ -1,22 +0,0 @@
: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); }
-134
View File
@@ -1,134 +0,0 @@
: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;
}
}
-40
View File
@@ -1,40 +0,0 @@
.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; }
}
-49
View File
@@ -1,49 +0,0 @@
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' })
}
}
-45
View File
@@ -1,45 +0,0 @@
.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; }
}
-46
View File
@@ -1,46 +0,0 @@
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' })
}
-13
View File
@@ -1,13 +0,0 @@
export * from './Msg'
export * from './Achv'
import ToastAchvContainer from './Achv'
import ToastMsgContainer from './Msg'
export const ToastContainer = () => (
<>
<ToastAchvContainer />
<ToastMsgContainer />
</>
)
export default ToastContainer
-13
View File
@@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"jsx": "react-jsx",
"types": ["react", "vite/client"],
"jsxImportSource": "react",
"paths": {
"@/*": ["./src/*"],
},
},
"include": ["**/*"]
}
-33
View File
@@ -1,33 +0,0 @@
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],
},
},
},
},
})
-31
View File
@@ -1,31 +0,0 @@
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],
},
},
},
},
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-23
View File
@@ -1,23 +0,0 @@
import { defineConfig } from 'eslint/config'
import parser from '@typescript-eslint/parser'
import plugin from '@typescript-eslint/eslint-plugin'
export default defineConfig([
{
ignores: ['dist/**', 'node_modules/**'],
files: ['**/*.{js,jsx,ts,tsx}'],
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
// @ts-ignore
plugins: { '@typescript-eslint': plugin },
},
])
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

+8 -1
View File
@@ -9,9 +9,16 @@
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<title>人生重开模拟器</title>
<script type="module" crossorigin src="./assets/index-Bqqg4haL.js"></script>
<link rel="modulepreload" crossorigin href="./assets/data-achievement-BG6OaHO5.js">
<link rel="modulepreload" crossorigin href="./assets/data-age-DvyW16Y1.js">
<link rel="modulepreload" crossorigin href="./assets/data-character-DyBCJvhr.js">
<link rel="modulepreload" crossorigin href="./assets/data-event-BJK1aZyi.js">
<link rel="modulepreload" crossorigin href="./assets/data-specialthanks-Dsslz6AY.js">
<link rel="modulepreload" crossorigin href="./assets/data-talent-DYCgNa1H.js">
<link rel="stylesheet" crossorigin href="./assets/index-ITDZ88G3.css">
</head>
<body>
<div id="remake"></div>
<script type="module" src="src/index.tsx"></script>
</body>
</html>
-48
View File
@@ -1,48 +0,0 @@
{
"name": "remake",
"type": "module",
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"dev": "pnpm --filter @remake/web dev",
"dev:prod": "pnpm --filter @remake/web dev:prod",
"build:web": "pnpm --filter @remake/web build",
"build:data": "pnpm --filter @remake/data build",
"preview": "pnpm --filter @remake/web preview",
"start": "pnpm --filter @remake/console start",
"lint": "bunx --bun eslint",
"lint:console": "pnpm --filter @remake/console lint",
"lint:web": "pnpm --filter @remake/web lint",
"lint:core": "pnpm --filter @remake/core lint",
"lint:data": "pnpm --filter @remake/data lint",
"lint:condition": "pnpm --filter @remake/condition lint",
"lint:hooks": "pnpm --filter @remake/hooks lint",
"fix": "bunx --bun eslint --fix",
"fix:console": "pnpm --filter @remake/console lint --fix",
"fix:web": "pnpm --filter @remake/web lint --fix",
"fix:core": "pnpm --filter @remake/core lint --fix",
"fix:data": "pnpm --filter @remake/data lint --fix",
"fix:condition": "pnpm --filter @remake/condition lint --fix",
"fix:hooks": "pnpm --filter @remake/hooks lint --fix",
"test": "pnpm --filter @remake/web test",
"test:console": "pnpm --filter @remake/console test",
"test:web": "pnpm --filter @remake/web test",
"test:core": "pnpm --filter @remake/core test",
"test:data": "pnpm --filter @remake/data test",
"test:condition": "pnpm --filter @remake/condition test",
"test:hooks": "pnpm --filter @remake/hooks test"
},
"devDependencies": {
"@types/bun": "1.3.14",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@typescript/native": "npm:typescript@^7.0.2",
"eslint": "^10.8.1",
"typescript": "npm:@typescript/typescript6@^6.0.2"
},
"workspaces": [
"packages/*",
"apps/*",
"thirdparty/*"
]
}
-118
View File
@@ -1,118 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { check } from '.'
// 定义属性 mock 字典的类型契约
interface MockProperties {
[key: string]: number | number[]
}
/**
*
*/
function withProp(prop: MockProperties) {
return (condition: string): boolean => check(prop, condition)
}
describe('condition', () => {
// 实例化一个带满各种脏数据(数字、数组、空数组)的强类型测试代理
const check = withProp({
n1: 0,
n2: -10,
n3: 10,
nl1: [1, 2, 3],
nl2: [0],
nl3: [],
})
test('gt(>)', () => {
expect(check('n3>11')).toBe(false)
expect(check('n3>10')).toBe(false)
expect(check('n3>9')).toBe(true)
})
test('gte(>=)', () => {
expect(check('n3>=11')).toBe(false)
expect(check('n3>=10')).toBe(true)
expect(check('n3>=9')).toBe(true)
})
test('lt(<)', () => {
expect(check('n3<9')).toBe(false)
expect(check('n3<10')).toBe(false)
expect(check('n3<11')).toBe(true)
})
test('lte(<=)', () => {
expect(check('n3<=9')).toBe(false)
expect(check('n3<=10')).toBe(true)
expect(check('n3<=11')).toBe(true)
})
test('eq(=)', () => {
expect(check('n3=9')).toBe(false)
expect(check('n3=10')).toBe(true)
expect(check('n3=11')).toBe(false)
expect(check('nl1=0')).toBe(false)
expect(check('nl1=1')).toBe(true)
expect(check('nl1=2')).toBe(true)
expect(check('nl1=3')).toBe(true)
})
test('ne(!=)', () => {
expect(check('n3!=9')).toBe(true)
expect(check('n3!=10')).toBe(false)
expect(check('n3!=11')).toBe(true)
expect(check('nl1!=0')).toBe(true)
expect(check('nl1!=1')).toBe(false)
expect(check('nl1!=2')).toBe(false)
expect(check('nl1!=3')).toBe(false)
})
test('in(?)', () => {
expect(check('n3?[1,2,3]')).toBe(false)
expect(check('n3?[10,11,12]')).toBe(true)
expect(check('nl1?[0,1]')).toBe(true)
expect(check('nl2?[1,2,3]')).toBe(false)
expect(check('nl3?[1,2,3]')).toBe(false)
expect(check('nl2?[]')).toBe(false)
expect(check('nl3?[]')).toBe(false)
})
test('notin(!)', () => {
expect(check('n3![1,2,3]')).toBe(true)
expect(check('n3![10,11,12]')).toBe(false)
expect(check('nl1![0,1]')).toBe(false)
expect(check('nl2![1,2,3]')).toBe(true)
expect(check('nl3![1,2,3]')).toBe(true)
expect(check('nl2![]')).toBe(true)
expect(check('nl3![]')).toBe(true)
})
test('and(&)', () => {
expect(check('n1>=0&n2<0')).toBe(true)
expect(check('n2>0&n3>0')).toBe(false)
expect(check('n2<0&n3<0')).toBe(false)
expect(check('n2<0&n3>0')).toBe(true)
expect(check('n1=0&n2<0&n3>0')).toBe(true)
expect(check('n1=0&n2>0&n3>0')).toBe(false)
})
test('or(|)', () => {
expect(check('n1>=0|n2<0')).toBe(true)
expect(check('n2>0|n3>0')).toBe(true)
expect(check('n2<0|n3<0')).toBe(true)
expect(check('n2<0|n3>0')).toBe(true)
expect(check('n2>0|n3<0')).toBe(false)
expect(check('n1=0|n2<0|n3>0')).toBe(true)
expect(check('n1=0|n2>0|n3>0')).toBe(true)
expect(check('n1!=0|n2>0|n3<0')).toBe(false)
})
test('mix', () => {
expect(check('(n1=0|n2<0|n3>0)&(n1=0|n2>0|n3>0)')).toBe(true)
expect(check('(n1=0|n2<0|n3>0)&(n1!=0|n2>0|n3<0)')).toBe(false)
expect(check('n1=0|n2<0|n3>0&n1!=0|n2>0|n3<0')).toBe(true)
expect(check('(n1>0|n1?[-10,0])&(n2>0|n3![0,1])')).toBe(true)
expect(check('(n1>0&n1?[-10,0])|(n2<0&n3![0,1])')).toBe(true)
})
})
-190
View File
@@ -1,190 +0,0 @@
export type ConditionNode = string | ConditionTree
export interface ConditionTree extends Array<ConditionNode> {}
export type PropertyContainer = Record<string, any>
export function parse(condition: string): ConditionTree {
const conditions: ConditionTree = []
const length = condition.length
const stack: ConditionTree[] = []
stack.unshift(conditions)
let cursor = 0
const catchString = (i: number): void => {
const str = condition.substring(cursor, i).trim()
cursor = i
if (str) stack[0]?.push(str)
}
for (let i = 0; i < length; i++) {
switch (condition[i]) {
case ' ':
continue
case '(': {
catchString(i)
cursor++
const sub: ConditionTree = []
stack[0]?.push(sub)
stack.unshift(sub)
break
}
case ')':
catchString(i)
cursor++
stack.shift()
break
case '|':
case '&':
catchString(i)
catchString(i + 1)
break
default:
continue
}
}
catchString(length)
return conditions
}
export function check(property: PropertyContainer, condition: string): boolean {
const conditions = parse(condition)
return checkParsedConditions(property, conditions)
}
/**
*
*/
function checkParsedConditions(
property: PropertyContainer,
conditions: ConditionNode,
): boolean {
// 如果已经剥离到了最底层的纯字符串表达式(如 'AGE>10'),直接移交核心原子判定器
if (!Array.isArray(conditions)) {
return checkProp(property, conditions)
}
if (conditions.length === 0) return true
if (conditions.length === 1) {
return checkParsedConditions(property, conditions[0]!)
}
let ret = checkParsedConditions(property, conditions[0]!)
for (let i = 1; i < conditions.length; i += 2) {
const operator = conditions[i]
const nextNode = conditions[i + 1]
// 防御性拦截,防止表达式残缺或配置错误引发崩溃
if (nextNode === undefined) return false
switch (operator) {
case '&':
if (ret) {
ret = checkParsedConditions(property, nextNode)
}
break
case '|':
if (ret) return true
ret = checkParsedConditions(property, nextNode)
break
default:
return false
}
}
return ret
}
function checkProp(property: PropertyContainer, condition: string): boolean {
const length = condition.length
let i = condition.search(/[><!?=]/)
// 防御处理:如果没有找到任何操作符,视为非法表达式直接拒签
if (i === -1) return false
const prop = condition.substring(0, i)
// 判断是否是双字符操作符(如 >=, <=, !=),若是则向前多吞一个字符位
const isDoubleSymbol = condition[i + 1] === '='
const symbol = condition.substring(i, (i += isDoubleSymbol ? 2 : 1))
const d = condition.substring(i, length)
const propData = property[prop]
const conditionData: number | any[] =
d[0] === '[' ? JSON.parse(d) : Number(d)
switch (symbol) {
case '>':
return (propData ?? 0) > conditionData
case '<':
return (propData ?? 0) < conditionData
case '>=':
return (propData ?? 0) >= conditionData
case '<=':
return (propData ?? 0) <= conditionData
case '=':
if (propData instanceof Set) {
return propData.has(conditionData)
}
if (Array.isArray(propData)) {
return propData.includes(conditionData)
}
return (propData ?? 0) == conditionData
case '!=':
if (propData instanceof Set) {
return !propData.has(conditionData)
}
if (Array.isArray(propData)) {
return !propData.includes(conditionData)
}
return (propData ?? 0) != conditionData
case '?': // 🌟 包含判定符(如 属性值 是否在 [1,2,3] 数组范围内)
if (propData instanceof Set) {
if (Array.isArray(conditionData)) {
for (const c of conditionData) {
if (propData.has(c)) return true
}
}
return false
}
if (Array.isArray(propData)) {
if (Array.isArray(conditionData)) {
for (const p of propData) {
if (conditionData.includes(p)) return true
}
}
return false
}
if (Array.isArray(conditionData)) {
return conditionData.includes(propData)
}
return false
case '!': // 🌟 排除判定符(如 属性值 是否不存在于 [1,2,3] 数组中)
if (propData instanceof Set) {
if (Array.isArray(conditionData)) {
for (const c of conditionData) {
if (propData.has(c)) return false
}
}
return true
}
if (Array.isArray(propData)) {
if (Array.isArray(conditionData)) {
for (const p of propData) {
if (conditionData.includes(p)) return false
}
}
return true
}
if (Array.isArray(conditionData)) {
return !conditionData.includes(propData)
}
return true
default:
return false
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@remake/condition",
"type": "module",
"version": "3.0.0",
"main": "./index.ts",
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"lint": "bunx --bun eslint .",
"test": "bun test"
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
-29
View File
@@ -1,29 +0,0 @@
{
"name": "@remake/core",
"type": "module",
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"main": "src/index.ts",
"scripts": {
"lint": "bunx --bun eslint .",
"test": "bun test"
},
"dependencies": {
"@remake/condition": "workspace:*",
"@remake/data": "workspace:*",
"@remake/vitex": "workspace:*",
"immer": "^11.1.15"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { createState } from './state'
import { trigger } from './achievement'
import { AchievementOpportunity } from '@remake/data'
import { enableMapSet } from 'immer'
enableMapSet()
describe('Achievement', () => {
const profile = {
times: 0,
talents: new Set([]),
achievements: new Set([]),
events: new Set([]),
}
const allocation = {
charm: 5,
intelligence: 5,
strength: 5,
money: 5,
spirit: 5,
}
test('trigger [times:500]', () => {
const p = { ...profile, times: 500 }
const result = trigger(
AchievementOpportunity.End,
createState(allocation, []),
p,
)
expect(result.state.achievements).toContain(101) // 既视感
expect(result.state.achievements).toContain(102) // 孟婆愁
expect(result.state.achievements).toContain(103) // 所有人都是我
expect(result.state.achievements).toContain(104) // Rewrite
expect(result.triggers).toContain(101)
expect(result.triggers).toContain(102)
expect(result.triggers).toContain(103)
expect(result.triggers).toContain(104)
})
})
-30
View File
@@ -1,30 +0,0 @@
import type { Achievement, AchievementOpportunity } from '@remake/data'
import { achievements } from '@remake/data'
import type { GameState, ProfileState } from './state'
import { createFlatState } from './state'
import { check } from '@remake/condition'
import { produce } from 'immer'
import type { TriggerResult } from './game'
const OpportunityMap = Map.groupBy(
achievements.keys(),
a => achievements.get(a)!.opportunity,
)
export function trigger(
opportunity: AchievementOpportunity,
state: GameState,
profile: ProfileState,
): TriggerResult<Achievement['id']> {
const flatState = createFlatState(state, profile)
const triggers = OpportunityMap.get(opportunity)!.filter(a => {
if (state.achievements.has(a)) return false
if (profile.achievements.has(a)) return false
const { condition } = achievements.get(a)!
return check(flatState, condition)
})
const newState = produce(state, draft => {
draft.achievements = new Set([...draft.achievements, ...triggers])
})
return { state: newState, triggers }
}
-60
View File
@@ -1,60 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { pullChara, uniqueGenerate } from './character'
import { enableMapSet } from 'immer'
enableMapSet()
describe('Character', () => {
const sum = (map: Map<any, number>) =>
Array.from(map.values()).reduce((acc, val) => acc + val, 0)
test('pull', () => {
let result = pullChara(
{ count: 10, knife: 10 },
{ times: 0, drawns: new Map() },
)
expect(result.characters).not.toContainValue(null)
expect(result.characters.length).toBe(10)
expect(result.times.times).toBe(10)
expect(result.times.drawns.size).toBe(10)
result = pullChara({ count: 3, knife: 10 }, result.times)
expect(result.characters).not.toContainValue(null)
expect(result.characters.length).toBe(3)
expect(result.times.times).toBe(13)
expect(sum(result.times.drawns)).toBe(13)
result = pullChara({ count: 3, knife: 10 }, result.times)
expect(result.characters).not.toContainValue(null)
expect(result.characters.length).toBe(3)
expect(result.times.times).toBe(16)
expect(sum(result.times.drawns)).toBe(16)
result = pullChara({ count: 20, knife: 1 }, result.times)
expect(result.characters).not.toContainValue(null)
expect(result.characters.length).toBe(20)
expect(result.times.times).toBe(36)
expect(sum(result.times.drawns)).toBe(36)
})
const wr = (s: number, e: number) => {
const length = e - s + 1
return Array.from(
{ length },
(_, i) => [s + i, Math.min(i + 1, length - i)] as const,
)
}
test('unique', () => {
const unique = uniqueGenerate({ prop: wr(0, 10), talent: wr(1, 5) })
expect(unique.property).toHaveProperty('CHR')
expect(unique.property).toHaveProperty('INT')
expect(unique.property).toHaveProperty('STR')
expect(unique.property).toHaveProperty('MNY')
expect(unique.property.CHR).toBeGreaterThanOrEqual(0)
expect(unique.property.INT).toBeGreaterThanOrEqual(0)
expect(unique.property.STR).toBeGreaterThanOrEqual(0)
expect(unique.property.MNY).toBeGreaterThanOrEqual(0)
expect(unique.talent.length).toBeGreaterThanOrEqual(0)
expect(unique.property.CHR).toBeLessThanOrEqual(10)
expect(unique.property.INT).toBeLessThanOrEqual(10)
expect(unique.property.STR).toBeLessThanOrEqual(10)
expect(unique.property.MNY).toBeLessThanOrEqual(10)
expect(unique.talent.length).toBeLessThanOrEqual(5)
})
})
-98
View File
@@ -1,98 +0,0 @@
import type { Character, CharacterProperty, Talent } from '@remake/data'
import { characters, talents } from '@remake/data'
import { type RNG, type WeightItem, pick, pickWeight } from '@remake/vitex'
import type { Allocation } from './state'
export type BaseChara = Omit<Character, 'id' | 'name'>
export type BaseAlloc = Omit<Allocation, 'spirit'>
export interface UniqueGenCfg {
prop: WeightItem<number>[]
talent: WeightItem<number>[]
}
export function uniqueGenerate(config: UniqueGenCfg, rng?: RNG) {
const CHR = pickWeight(config.prop, rng) ?? 0
const INT = pickWeight(config.prop, rng) ?? 0
const STR = pickWeight(config.prop, rng) ?? 0
const MNY = pickWeight(config.prop, rng) ?? 0
let count = pickWeight(config.talent, rng) ?? 0
const property = { CHR, INT, STR, MNY }
const ids = Array.from(talents.keys())
const picked = new Set<Talent['id']>()
while (count > 0) {
const t = pick(ids, rng)!
if (picked.has(t)) continue
if (talents.get(t)!.exclusive) continue
picked.add(t)
count--
}
return { property, talent: Array.from(picked) } satisfies BaseChara
}
export interface PullCharaOpt {
count: number
knife: number
}
export interface PullCharaTms {
times: number
drawns: Map<Character['id'], number>
}
export interface PullCharaRet {
characters: Character['id'][]
times: PullCharaTms
}
export function pullChara(
opt: PullCharaOpt,
tms: PullCharaTms = { times: 0, drawns: new Map() },
rng?: RNG,
): PullCharaRet {
const { count, knife } = opt
const drawns = new Map(tms.drawns)
const picked = new Set<Character['id']>()
for (let i = 0; i < count; i++) {
const weightMap = deriveWeightMap(tms.times + i, knife, drawns)
picked.forEach(id => weightMap.delete(id))
const id = pickWeight(Array.from(weightMap.entries()), rng)!
picked.add(id)
drawns.set(id, (drawns.get(id) ?? 0) + 1)
}
return {
characters: Array.from(picked),
times: { times: tms.times + count, drawns },
}
}
function deriveWeightMap(
times: number,
knife: number,
drawns: PullCharaTms['drawns'],
): Map<Character['id'], number> {
const max = Math.max(0, ...drawns.values())
const base = times - knife * Math.floor((times - max) / (knife || 1)) || 1
return new Map(
Array.from(characters.keys(), id => [id, base - (drawns.get(id) ?? 0)]),
)
}
export function charaPropToBaseAlloc(props: CharacterProperty): BaseAlloc {
return {
charm: props.CHR,
intelligence: props.INT,
strength: props.STR,
money: props.MNY,
}
}
function convert(chara: BaseChara) {
return {
allocation: charaPropToBaseAlloc(chara.property),
talents: chara.talent,
}
}
export function startChara(id: Character['id']) {
return convert(characters.get(id)!)
}
export function startUnique(chara: BaseChara) {
return convert(chara)
}
-39
View File
@@ -1,39 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { createState } from './state'
import { trigger } from './event'
import { enableMapSet } from 'immer'
enableMapSet()
describe('Event', () => {
const profile = {
times: 0,
talents: new Set([]),
achievements: new Set([]),
events: new Set([]),
}
const allocation = {
charm: 10,
intelligence: 10,
strength: 0,
money: 0,
spirit: 5,
}
test('branch 10003:0 [玉佩]', () => {
const result = trigger(
10003,
createState(allocation, [1001, 1002, 1003]),
profile,
)
expect(result.state.life).toBe(1)
expect(result.state.events).toEqual(new Set([10003, 10004]))
expect(result.triggers).toEqual([10003, 10004])
})
test('branch 10003:1 [死了]', () => {
const result = trigger(10003, createState(allocation, []), profile)
expect(result.state.life).toBe(0)
expect(result.state.events).toEqual(new Set([10003, 10000]))
expect(result.triggers).toEqual([10003, 10000])
})
})
-52
View File
@@ -1,52 +0,0 @@
import { type Event, events } from '@remake/data'
import type { Properties, GameState, ProfileState } from './state'
import { propsEffect, createFlatState } from './state'
import { check as checkCondition } from '@remake/condition'
import type { TriggerResult } from './game'
import { produce } from 'immer'
export function check(
event: Event['id'],
state: GameState,
profile: ProfileState,
) {
const { include, exclude, NoRandom } = events.get(event)!
if (NoRandom) return false
const flatState = createFlatState(state, profile)
if (exclude && checkCondition(flatState, exclude)) return false
if (include) return checkCondition(flatState, include)
return true
}
export function trigger(
eventId: number,
state: GameState,
profile: ProfileState,
): TriggerResult<Event['id']> {
const { effect, branch } = events.get(eventId)!
const newState = produce(state, draft => {
draft.events.add(eventId)
if (!effect) return
if (effect.LIF) draft.life += effect.LIF
const pe: Partial<Properties> = {}
if (effect.CHR) pe.charm = effect.CHR
if (effect.INT) pe.intelligence = effect.INT
if (effect.STR) pe.strength = effect.STR
if (effect.MNY) pe.money = effect.MNY
if (effect.SPR) pe.spirit = effect.SPR
draft.props = propsEffect(draft.props, pe)
})
const flatState = createFlatState(newState, profile)
if (branch) {
for (const { condition, event } of branch) {
if (checkCondition(flatState, condition)) {
const result = trigger(event, newState, profile)
return {
state: result.state,
triggers: [eventId, ...result.triggers],
}
}
}
}
return { state: newState, triggers: [eventId] }
}
-104
View File
@@ -1,104 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { createState, propsEffect, summary as stateSummary } from './state'
import { pick, start, next, summary, end } from './game'
import { produce } from 'immer'
import { enableMapSet } from 'immer'
enableMapSet()
describe('Achievement', () => {
const profile = {
times: 0,
talents: new Set([]),
achievements: new Set([]),
events: new Set([]),
}
const allocation = {
charm: 5,
intelligence: 5,
strength: 5,
money: 5,
spirit: 5,
}
test('talentsPicked', () => {
// 挑战者、阴间福袋、轮盘赌
const result = pick([1122, 1145, 1146])
expect(result.talents.chains.size).toBeGreaterThan(1)
expect(result.additionalPoints.source).toContainEqual({
talent: 1122,
points: -20,
})
})
test('start', () => {
const result = start(profile, allocation, [1001, 1111, 1130])
expect(result.state.achievements).toContain(228) // 只通一窍
})
test('next', () => {
// 白色胶囊
const state = produce(
createState(allocation, [1001, 1002, 1103]),
draft => {
draft.props = propsEffect(draft.props, { age: 10 })
},
)
const result = next(state, profile)
expect(result.state.props.current.age).toBe(10) // 十岁
expect(result.state.events).toContain(10588) // 无事发生
})
test('summary', () => {
const state = produce(
createState(allocation, [1001, 1111, 1130]),
draft => {
draft.props = propsEffect(draft.props, {
charm: 1000,
intelligence: 1000,
strength: 1000,
money: 1000,
spirit: 1000,
age: 501,
})
},
)
const result = summary(state, profile)
const s = stateSummary(state)
expect(s).toBeGreaterThan(10000) // 总评大于10000
expect(result.state.achievements).toContain(245) // 传奇人物
expect(result.state.achievements).toContain(246) // 不可思议
expect(result.state.achievements).toContain(247) // 大能者
expect(result.state.achievements).toContain(248) // 无上存在
})
test('end', () => {
const p = { ...profile, times: 500, talents: new Set([1002, 1003]) }
const state = produce(
createState(allocation, [1001, 1111, 1130]),
draft => {
draft.props = propsEffect(draft.props, {
charm: 1000,
intelligence: 1000,
strength: 1000,
money: 1000,
spirit: 1000,
age: 501,
})
},
)
const result = end(state, p)
expect(result.profile.achievements).toContain(101) // 既视感
expect(result.profile.achievements).toContain(102) // 孟婆愁
expect(result.profile.achievements).toContain(103) // 所有人都是我
expect(result.profile.achievements).toContain(104) // Rewrite
expect(result.achievements).toContain(101)
expect(result.achievements).toContain(102)
expect(result.achievements).toContain(103)
expect(result.achievements).toContain(104)
expect(result.profile.talents).toContain(1001)
expect(result.profile.talents).toContain(1002)
expect(result.profile.talents).toContain(1003)
expect(result.profile.talents).toContain(1111)
expect(result.profile.talents).toContain(1130)
})
})
-107
View File
@@ -1,107 +0,0 @@
import type { Achievement, Event, Talent } from '@remake/data'
import { ages, AchievementOpportunity as Ao } from '@remake/data'
import type { GameState, ProfileState } from './state'
import { createState, nextProfile, propsEffect } from './state'
import { summary as stateSummary } from './state'
import type { ReplacementResult, AdditionalPoints } from './talent'
import { pull, exclude, replacement, additionalPoints } from './talent'
import { trigger as ttr } from './talent'
import { trigger as atr } from './achievement'
import { trigger as etr, check as ec } from './event'
import type { RNG } from '@remake/vitex'
import { pickWeight } from '@remake/vitex'
import { produce, enableMapSet } from 'immer'
enableMapSet()
export interface TriggerResult<T> {
state: GameState
triggers: T[]
}
export interface PickResult {
talents: ReplacementResult
additionalPoints: AdditionalPoints
}
export function pick(talents: Iterable<Talent['id']>, rng?: RNG): PickResult {
const r = replacement(talents, rng)
const ap = additionalPoints(r.talents)
return { talents: r, additionalPoints: ap }
}
export interface StartResult {
state: GameState
achievements: Achievement['id'][]
}
export function start(
profile: ProfileState,
...args: Parameters<typeof createState>
): StartResult {
const state = createState(...args)
const ar = atr(Ao.Start, state, profile)
return { state: ar.state, achievements: ar.triggers }
}
export interface NextResult {
state: GameState
age: number
achievements: Achievement['id'][]
events: Event['id'][]
talents: Talent['id'][]
end: boolean
}
export function next(
state: GameState,
profile: ProfileState,
rng?: RNG,
): NextResult {
let s = produce(state, draft => {
draft.props = propsEffect(state.props, { age: 1 })
})
const age = s.props.current.age
const tr = ttr(s, profile, rng)
const events = ages
.get(age)!
.event.filter(([e]) => ec(e, tr.state, profile))
const event = pickWeight(events, rng)!
const er = etr(event, tr.state, profile)
const ar = atr(Ao.Trajectory, er.state, profile)
const end = ar.state.life < 1
return {
state: ar.state,
age,
achievements: ar.triggers,
events: er.triggers,
talents: tr.triggers,
end,
}
}
export interface SummaryResult {
state: GameState
summary: number
achievements: Achievement['id'][]
}
export function summary(
state: GameState,
profile: ProfileState,
): SummaryResult {
const ar = atr(Ao.Summary, state, profile)
const s = stateSummary(ar.state)
return { state: ar.state, summary: s, achievements: ar.triggers }
}
export interface EndResult {
profile: ProfileState
achievements: Achievement['id'][]
}
export function end(
state: GameState,
profile: ProfileState,
locked?: Talent['id'][],
) {
const ar = atr(Ao.End, state, profile)
const p = nextProfile(profile, ar.state, locked)
return { profile: p, achievements: ar.triggers }
}
export { pull, exclude }
-11
View File
@@ -1,11 +0,0 @@
export type { GameState, ProfileState, Properties, Allocation } from './state'
export type { PullOptions, ReplacementResult } from './talent'
export type { AdditionalPoint, AdditionalPoints } from './talent'
export type { PullCharaOpt, PullCharaTms, PullCharaRet } from './character'
export type { BaseChara, UniqueGenCfg } from './character'
export type { PickResult, StartResult } from './game'
export type { NextResult, SummaryResult, EndResult } from './game'
export { pull, exclude } from './talent'
export { uniqueGenerate, pullChara, charaPropToBaseAlloc } from './character'
export { startUnique, startChara } from './character'
export { pick, start, next, summary, end } from './game'
-248
View File
@@ -1,248 +0,0 @@
import { expect, test, describe } from 'bun:test'
import { parse, type ConditionNode } from '@remake/condition'
import { achievements, events, talents } from '@remake/data'
import type { FlatState, GameState, ProfileState, Properties } from './state'
import { createFlatState, createHLProperties, propsEffect } from './state'
import { SupportedFlatStateKeys } from './state'
import { enableMapSet } from 'immer'
enableMapSet()
/**
*
*/
function extractKeysFromNode(
node: ConditionNode,
properties: Set<keyof FlatState>,
) {
if (Array.isArray(node)) {
for (const subNode of node) {
extractKeysFromNode(subNode, properties)
}
} else {
if (node === '&' || node === '|') return
// 匹配操作符(><!?=)前面的属性名部分
const match = node.match(/^([^><!?=]+)/)
if (match && match) {
properties.add(match[1]!.trim() as keyof FlatState)
}
}
}
/**
* 使
*/
function getPropertiesFromCondition(condition: string): Set<keyof FlatState> {
const parsedConditions = parse(condition)
const properties = new Set<keyof FlatState>()
extractKeysFromNode(parsedConditions, properties)
return properties
}
describe('State', () => {
test('flat cover event(include, exclude, branch)', () => {
const unmappedKeys = new Set<string>()
for (const item of events.values()) {
const conditionsToTrack: { label: string; text: string }[] = []
if (item.include) {
conditionsToTrack.push({ label: 'include', text: item.include })
}
if (item.exclude) {
conditionsToTrack.push({ label: 'exclude', text: item.exclude })
}
if (item.branch && Array.isArray(item.branch)) {
item.branch.forEach((branchObj, index) => {
if (branchObj && branchObj.condition) {
conditionsToTrack.push({
label: `branch[${index}].condition (目标事件ID: ${branchObj.event})`,
text: branchObj.condition,
})
}
})
}
for (const { label, text } of conditionsToTrack) {
const usedProperties = getPropertiesFromCondition(text)
for (const key of usedProperties) {
if (!SupportedFlatStateKeys.has(key)) {
unmappedKeys.add(
`[事件 ID: ${item.id}] 的 "${label}" 字段使用了未定义的键名: "${key}" (完整条件: "${text}")`,
)
}
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现事件配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
test('flat cover talent(condition)', () => {
const unmappedKeys = new Set<string>()
const allTalentItems = talents.values()
for (const item of allTalentItems) {
const conditionStr = item.condition
if (!conditionStr) continue
const usedProperties = getPropertiesFromCondition(conditionStr)
for (const key of usedProperties) {
if (!SupportedFlatStateKeys.has(key)) {
unmappedKeys.add(
`[天赋 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`,
)
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现天赋配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
test('flat cover achievement(condition)', () => {
const unmappedKeys = new Set<string>()
const allAchievementItems = achievements.values()
for (const item of allAchievementItems) {
const conditionStr = item.condition
if (!conditionStr) continue
const usedProperties = getPropertiesFromCondition(conditionStr)
for (const key of usedProperties) {
if (!SupportedFlatStateKeys.has(key)) {
unmappedKeys.add(
`[成就 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`,
)
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现成就配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
test('flat get', () => {
const game = {
props: {
lowest: {
age: 1,
charm: 2,
intelligence: 3,
strength: 4,
money: 5,
spirit: 6,
},
current: {
age: 11,
charm: 12,
intelligence: 13,
strength: 14,
money: 15,
spirit: 16,
},
highest: {
age: 21,
charm: 22,
intelligence: 23,
strength: 24,
money: 25,
spirit: 26,
},
},
life: 31,
talents: new Set([33]),
events: new Set([34, 35]),
achievements: new Set([36]),
} as GameState
const profile = {
times: 41,
external: 42,
talents: new Set([43]),
achievements: new Set([44]),
events: new Set([45, 46]),
} as ProfileState
const flatState = createFlatState(game, profile)
expect(flatState['AGE']).toBe(game.props.current.age)
expect(flatState['CHR']).toBe(game.props.current.charm)
expect(flatState['INT']).toBe(game.props.current.intelligence)
expect(flatState['STR']).toBe(game.props.current.strength)
expect(flatState['MNY']).toBe(game.props.current.money)
expect(flatState['SPR']).toBe(game.props.current.spirit)
expect(flatState['HAGE']).toBe(game.props.highest.age)
expect(flatState['HCHR']).toBe(game.props.highest.charm)
expect(flatState['HINT']).toBe(game.props.highest.intelligence)
expect(flatState['HSTR']).toBe(game.props.highest.strength)
expect(flatState['HMNY']).toBe(game.props.highest.money)
expect(flatState['HSPR']).toBe(game.props.highest.spirit)
expect(flatState['LAGE']).toBe(game.props.lowest.age)
expect(flatState['LCHR']).toBe(game.props.lowest.charm)
expect(flatState['LINT']).toBe(game.props.lowest.intelligence)
expect(flatState['LSTR']).toBe(game.props.lowest.strength)
expect(flatState['LMNY']).toBe(game.props.lowest.money)
expect(flatState['LSPR']).toBe(game.props.lowest.spirit)
expect(flatState['LIF']).toBe(game.life)
expect(flatState['TLT']).toEqual(game.talents)
expect(flatState['EVT']).toEqual(game.events)
expect(flatState['TMS']).toBe(profile.times)
expect(flatState['AEVT']).toEqual(profile.events)
expect(flatState['ATLT']).toEqual(profile.talents)
expect(flatState['AACH']).toEqual(profile.achievements)
expect(flatState['SUM']).toBe(
Math.floor((22 + 23 + 24 + 25 + 26) * 2 + 21 / 2),
)
})
const allocation = {
charm: 5,
intelligence: 5,
strength: 5,
money: 5,
spirit: 5,
}
test('createHLProperties', () => {
const props = createHLProperties(allocation)
expect(props.current.charm).toBe(allocation.charm)
expect(props.current.intelligence).toBe(allocation.intelligence)
expect(props.current.strength).toBe(allocation.strength)
expect(props.current.money).toBe(allocation.money)
expect(props.current.age).toBe(-1)
expect(props.current.spirit).toBe(5)
})
test('propsEffect', () => {
const props = createHLProperties(allocation)
const effect = {
age: 6,
charm: -2,
intelligence: 3,
strength: 4,
money: -5,
spirit: 7,
}
const effected = propsEffect(props, effect)
for (const key in effect) {
const k = key as keyof Properties
const c = props.current[k]
const be = c + effect[k]
expect(effected.current[k]).toBe(be)
expect(effected.highest[k]).toBe(Math.max(c, be))
expect(effected.lowest[k]).toBe(Math.min(c, be))
}
})
})
-205
View File
@@ -1,205 +0,0 @@
import type { Achievement, Event, Talent } from '@remake/data'
import { produce } from 'immer'
import { sum, keys } from '@remake/vitex'
/** 基础的属性 */
export interface Properties {
age: number
charm: number
intelligence: number
strength: number
money: number
spirit: number
}
export interface HLProperties {
current: Properties // 当前属性
highest: Properties // 历史最高属性
lowest: Properties // 历史最低属性
}
export type Allocation = Omit<Properties, 'age'>
export function createProperties(allocation: Allocation) {
return { ...allocation, age: -1 }
}
export function createHLProperties(allocation: Allocation) {
const current = createProperties(allocation)
return { current, highest: { ...current }, lowest: { ...current } }
}
export interface GameState {
props: HLProperties // 本局属性
life: number // 本局生命值
talents: Set<Talent['id']> // 本局拥有的天赋
events: Set<Event['id']> // 本局触发过的事件
achievements: Set<Achievement['id']> // 本局达成的成就
talentTriggers: Map<Talent['id'], number> // 本局天赋触发次数
}
/** 持久化存储的数据 */
export interface ProfileState {
times: number // 游戏次数
locked?: Talent['id'][] // 锁定的天赋
talents: Set<Talent['id']> // 拥有过的天赋
events: Set<Event['id']> // 触发过的事件
achievements: Set<Achievement['id']> // 达成的成就
highest?: Properties // 历史最高属性
lowest?: Properties // 历史最低属性
}
export function createState(
allocation: Allocation,
talents?: Iterable<Talent['id']>,
): GameState {
return {
props: createHLProperties(allocation),
life: 1,
talents: new Set(talents),
events: new Set(),
achievements: new Set(),
talentTriggers: new Map(),
}
}
export function summary({ props }: GameState) {
const { age, ...others } = props.highest
const s = sum(Object.values(others))
return Math.floor(s * 2 + age / 2)
}
export interface FlatState {
AGE: GameState['props']['current']['age']
CHR: GameState['props']['current']['charm']
INT: GameState['props']['current']['intelligence']
STR: GameState['props']['current']['strength']
MNY: GameState['props']['current']['money']
SPR: GameState['props']['current']['spirit']
HAGE: GameState['props']['highest']['age']
HCHR: GameState['props']['highest']['charm']
HINT: GameState['props']['highest']['intelligence']
HSTR: GameState['props']['highest']['strength']
HMNY: GameState['props']['highest']['money']
HSPR: GameState['props']['highest']['spirit']
LAGE: GameState['props']['lowest']['age']
LCHR: GameState['props']['lowest']['charm']
LINT: GameState['props']['lowest']['intelligence']
LSTR: GameState['props']['lowest']['strength']
LMNY: GameState['props']['lowest']['money']
LSPR: GameState['props']['lowest']['spirit']
LIF: GameState['life']
TLT: GameState['talents']
EVT: GameState['events']
TMS: ProfileState['times']
AEVT: ProfileState['events']
ATLT: ProfileState['talents']
AACH: ProfileState['achievements']
SUM: number
}
type FlatStateKey = keyof FlatState
interface FlatTarget {
game: GameState
profile: ProfileState
}
type FlatMapper<Key extends FlatStateKey> = (
state: FlatTarget,
) => FlatState[Key]
const FlatMappers: { [Key in FlatStateKey]: FlatMapper<Key> } = {
AGE: state => state.game.props.current.age,
CHR: state => state.game.props.current.charm,
INT: state => state.game.props.current.intelligence,
STR: state => state.game.props.current.strength,
MNY: state => state.game.props.current.money,
SPR: state => state.game.props.current.spirit,
HAGE: state => state.game.props.highest.age,
HCHR: state => state.game.props.highest.charm,
HINT: state => state.game.props.highest.intelligence,
HSTR: state => state.game.props.highest.strength,
HMNY: state => state.game.props.highest.money,
HSPR: state => state.game.props.highest.spirit,
LAGE: state => state.game.props.lowest.age,
LCHR: state => state.game.props.lowest.charm,
LINT: state => state.game.props.lowest.intelligence,
LSTR: state => state.game.props.lowest.strength,
LMNY: state => state.game.props.lowest.money,
LSPR: state => state.game.props.lowest.spirit,
LIF: state => state.game.life,
TLT: state => state.game.talents,
EVT: state => state.game.events,
TMS: state => state.profile.times,
AEVT: state => state.profile.events,
ATLT: state => state.profile.talents,
AACH: state => state.profile.achievements,
SUM: ({ game }) => summary(game),
}
export const SupportedFlatStateKeys = new Set(keys(FlatMappers))
const flatStateHandle = {
get<Key extends FlatStateKey>(target: FlatTarget, prop: Key) {
return FlatMappers[prop]?.(target)
},
set: () => true,
}
export function createFlatState(game: GameState, profile: ProfileState) {
return new Proxy({ game, profile }, flatStateHandle) as unknown as FlatState
}
export function propsEffect(hlp: HLProperties, effect: Partial<Properties>) {
return produce(hlp, draft => {
for (const key in effect) {
const prop = key as keyof Properties
const value = effect[prop]!
draft.current[prop] += value
draft.highest[prop] = Math.max(
draft.highest[prop],
draft.current[prop],
)
draft.lowest[prop] = Math.min(
draft.lowest[prop],
draft.current[prop],
)
}
})
}
export function highestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of keys(a)) {
result[key] = Math.max(a[key], b[key])
}
return result
}
export function lowestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of keys(a)) {
result[key] = Math.min(a[key], b[key])
}
return result
}
export function nextProfile(
profile: ProfileState,
state: GameState,
locked?: Talent['id'][],
) {
return {
times: profile.times + 1,
talents: profile.talents.union(state.talents),
events: profile.events.union(state.events),
achievements: profile.achievements.union(state.achievements),
highest: highestProperties(state.props.highest, profile.highest),
lowest: lowestProperties(state.props.lowest, profile.lowest),
locked,
}
}
-92
View File
@@ -1,92 +0,0 @@
import { expect, test, describe } from 'bun:test'
import type { ProfileState } from './state'
import type { PullOptions } from './talent'
import { pull, exclude, additionalPoints, replacement } from './talent'
import { talents } from '@remake/data'
import { produce } from 'immer'
import { enableMapSet } from 'immer'
enableMapSet()
describe('Talent', () => {
const profile = {
times: 0,
talents: new Set([]),
achievements: new Set([]),
events: new Set([]),
} as ProfileState
const options = {
count: 10,
rate: {
base: new Map([
[0, 1000 - 111],
[1, 100],
[2, 10],
[3, 1],
]),
additions: {},
},
} as PullOptions
test('pull external', () => {
const p = produce(profile, draft => {
draft.locked = [1001]
})
const result = pull(options, p)
expect(result.length).toBe(options.count)
expect(result).toContain(p.locked![0]!)
})
test('pull rate', () => {
const opts = produce(options, draft => {
draft.rate.additions = {
times: () =>
new Map([
[0, { mode: 'multiply', value: 0 }],
[1, { mode: 'multiply', value: 0 }],
[2, { mode: 'multiply', value: 0 }],
[3, { mode: 'multiply', value: 1 }],
]),
}
})
const result = pull(opts, profile)
expect(result.some(id => talents.get(id)?.grade === 3)).toBe(true)
})
test('exclude', () => {
expect(exclude(1003, [1004])).toBe(1004)
expect(exclude(1004, [1003])).toBe(1003)
expect(exclude(1001, [1002, 1003])).toBe(null)
})
test('additionalPoints', () => {
const result = additionalPoints([1001, 1002, 1003, 1007, 1019])
expect(result).toEqual({
source: [
{ talent: 1007, points: 2 },
{ talent: 1019, points: 4 },
],
points: 6,
})
})
test('replacement talent [阴间福袋]', () => {
const talent = 1145
const result = replacement([talent])
expect(result.talents.size).toBeGreaterThan(1)
expect(result.chains.get(talent)).toBeDefined()
const source = talents.get(talent)!.replacement!.talent!
const replaced = result.chains.get(talent)!.at(0)!
expect(source.map(t => t[0])).toContain(replaced)
})
test('replacement grade [橙色转盘]', () => {
const talent = 1144
const result = replacement([talent])
expect(result.talents.size).toBeGreaterThan(1)
expect(result.chains.get(talent)).toBeDefined()
const source = talents.get(talent)!.replacement!.grade!
const replaced = result.chains.get(talent)!.at(0)!
expect(talents.get(replaced)?.grade).toBe(source)
})
})
-219
View File
@@ -1,219 +0,0 @@
import { type Talent, type TalentGrade, talents } from '@remake/data'
import type { Properties, GameState, ProfileState } from './state'
import { propsEffect, createFlatState } from './state'
import { check } from '@remake/condition'
import { pick, pickWeight, type RNG } from '@remake/vitex'
import { produce } from 'immer'
import type { TriggerResult } from './game'
const Grades: TalentGrade[] = [0, 1, 2, 3] as const
const GradeMap = Map.groupBy(
talents.keys().filter(t => !talents.get(t)!.exclusive),
t => talents.get(t)!.grade,
)
export const count = talents.size
export type PullRate = Map<TalentGrade, number>
export type PullRateAddition = {
mode: 'add' | 'multiply'
value: number
}
export type PullRateAdditions = {
[key in keyof ProfileState]?: (
value: ProfileState[key],
) => Map<TalentGrade, PullRateAddition>
}
export interface RateOptions {
base: PullRate
additions: PullRateAdditions
}
const AddidtionInit = Grades.map(g => [g, 1]) as [TalentGrade, number][]
function talentRateWithAddition(
{ base, additions }: RateOptions,
profile: ProfileState,
) {
const rate = new Map(AddidtionInit)
for (const [key, getAddition] of Object.entries(additions)) {
const value = profile[key as keyof ProfileState]
for (const [k, v] of getAddition(value as any).entries()) {
const current = rate.get(k)!
if (v.mode === 'add') {
rate.set(k, current + v.value)
} else if (v.mode === 'multiply') {
rate.set(k, current * v.value)
}
}
}
for (const [g, addition] of rate.entries()) {
const b = base.get(g)
if (!b) throw new Error(`talent: base rate for grade ${g} is missing`)
rate.set(g, Math.max(b * addition, 0))
}
return rate
}
export interface PullOptions {
count: number
rate: RateOptions
}
export function pull(
options: PullOptions,
profile: ProfileState,
rng?: RNG,
): Talent['id'][] {
const rate = Array.from(
talentRateWithAddition(options.rate, profile).entries(),
)
const result = []
const map = new Map(Grades.map(g => [g, new Set(GradeMap.get(g))]))
if (profile.locked) {
result.push(...profile.locked)
if (result.length >= options.count) return result
for (const talent of result) {
const { grade } = talents.get(talent)!
map.get(grade)!.delete(talent)
}
}
for (let i = options.count - result.length; i > 0; i--) {
const grade = pickWeight(rate, rng)! ?? 0
const set = map.get(grade)!
if (set.size === 0) continue
const id = pick(Array.from(set), rng)!
result.push(id)
set.delete(id)
}
return result
}
export function exclude(talent: Talent['id'], list: Iterable<Talent['id']>) {
const { exclude } = talents.get(talent)!
for (const t of list) {
if (exclude) {
for (const e of exclude) {
if (t == e) return t
}
}
const excludeReverse = talents.get(t)!.exclude
if (excludeReverse) {
for (const e of excludeReverse) {
if (talent == e) return t
}
}
}
return null
}
function chainReplace(t: Talent['id'], ts: Set<Talent['id']>, rng?: RNG) {
const { replacement: r } = talents.get(t)!
if (!r) return null
let picked: Talent['id'] | null = null
if (r.talent) {
const filtered = r.talent.filter(([id]) => {
if (ts.has(id)) return false
return exclude(id, ts) == null
})
picked = pickWeight(filtered, rng)
} else if (r.grade) {
const filtered = GradeMap.get(r.grade)!.filter(id => {
if (ts.has(id)) return false
return exclude(id, ts) == null
})
picked = pick(filtered, rng)
}
if (!picked) return null
const nts = new Set(ts)
nts.add(picked)
const next = chainReplace(picked, nts, rng) as Talent['id'][] | null
if (next) return [picked, ...next]
return [picked]
}
export interface ReplacementResult {
talents: Set<Talent['id']>
chains: Map<Talent['id'], Talent['id'][]>
}
export function replacement(
list: Iterable<Talent['id']>,
rng?: RNG,
): ReplacementResult {
const set = new Set(list)
const chains = new Map() as ReplacementResult['chains']
for (const talent of list) {
const chain = chainReplace(talent, set, rng)
if (!chain) continue
chains.set(talent, chain)
chain.forEach(t => set.add(t))
}
return { talents: set, chains }
}
export interface AdditionalPoint {
talent: Talent['id']
points: number
}
export interface AdditionalPoints {
source: AdditionalPoint[]
points: number
}
export function additionalPoints(
list: Iterable<Talent['id']>,
): AdditionalPoints {
const source: AdditionalPoint[] = []
let total = 0
for (const id of list) {
const { points } = talents.get(id)!
if (!points) continue
source.push({ talent: id, points })
total += points
}
return { source, points: total }
}
const EffectRandomProperties = [
'money',
'strength',
'intelligence',
'charm',
'spirit',
] as (keyof Properties)[]
export function trigger(
state: GameState,
profile: ProfileState,
rng?: RNG,
): TriggerResult<Talent['id']> {
const flatState = createFlatState(state, profile)
const triggers: Talent['id'][] = []
for (const talent of state.talents) {
const { condition, max } = talents.get(talent)!
if (state.talentTriggers.get(talent) ?? 0 >= max) continue
if (condition && !check(flatState, condition)) continue
triggers.push(talent)
}
if (triggers.length === 0) return { state, triggers }
const newState = produce(state, draft => {
for (const talent of triggers) {
const times = draft.talentTriggers.get(talent) ?? 0
draft.talentTriggers.set(talent, times + 1)
const { effect } = talents.get(talent)!
if (!effect) continue
const pe = {} as Partial<Properties>
if (effect.CHR) pe.charm = effect.CHR
if (effect.INT) pe.intelligence = effect.INT
if (effect.STR) pe.strength = effect.STR
if (effect.MNY) pe.money = effect.MNY
if (effect.SPR) pe.spirit = effect.SPR
if (effect.RND) {
const key = pick(EffectRandomProperties, rng)!
pe[key] = (pe[key] ?? 0) || effect.RND
}
draft.props = propsEffect(draft.props, pe)
}
})
return { state: newState, triggers }
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
-3
View File
@@ -1,3 +0,0 @@
dist
node_modules
~$*.xlsx
-9
View File
@@ -1,9 +0,0 @@
## Bugs
### 复活Bug
age 400
-> 寿元终
-> 天赋复活
age 401
-> 无可随机事件
-> 报错

Some files were not shown because too many files have changed in this diff Show More