update: play property animation

update: event format field
fix: test
This commit is contained in:
Vick Scarlet
2026-08-12 21:57:49 +08:00
parent f439141ded
commit 2d35eda931
15 changed files with 110 additions and 155 deletions
+7 -44
View File
@@ -1,56 +1,19 @@
# Life Restart # 人生重开模拟器
<a href="https://discord.gg/U3qrf49NMQ"><img src="https://img.shields.io/discord/883382868427014255?color=%23FEE75C&label=Discord&logo=discord&logoColor=white&style=for-the-badge" /></a> <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) | 简体中文 [English](./README.md) | 简体中文
## 简介 ## 开发环境
- Game Life Restart 包管理: `pnpm`
运行时: `bun`
## 使用 ## 使用
<details> ### 开发
<summary><strong>网页版</strong></summary>
<br />
```bash ```bash
# 1. 下载项目代码 pnpm build:data
git clone https://github.com/VickScarlet/lifeRestart.git my-project
# 2. 进入目录安装依赖
cd my-project
pnpm install
# 3. 转换XLSX表
pnpm xlsx2json
# 4. 启动本地开发服务器
pnpm dev pnpm dev
``` ```
启动完成后打开浏览器访问 [http://localhost:5173](http://localhost:5173)。
</details>
<details>
<summary><strong>控制台版本</strong></summary>
<br />
```bash
node repl
```
</details>
## 其他版本
<details>
<summary><strong>版本列表</strong></summary>
<br />
- Cocos版:[gameall3d/LifeRestart_Cocos](https://github.com/gameall3d/LifeRestart_Cocos)
</details>
> 更多信息请参考 [官网文档](https://liferestart.syaro.io/)。
+7 -44
View File
@@ -1,56 +1,19 @@
# Life Restart # Remake
<a href="https://discord.gg/U3qrf49NMQ"><img src="https://img.shields.io/discord/883382868427014255?color=%23FEE75C&label=Discord&logo=discord&logoColor=white&style=for-the-badge" /></a> <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) English | [简体中文](./README-zh_CN.md)
## Introduction ## Environment
- Game Life Restart Package Manager: `pnpm`
Runtime: `bun`
## Usage ## Usage
<details> ### Dev
<summary><strong>Web Version</strong></summary>
<br />
```bash ```bash
# 1. Clone project code. pnpm build:data
git clone git@github.com:VickScarlet/lifeRestart.git my-project
cd my-project
# 2. Installation dependence.
pnpm install
# 3. Transform XLSX
pnpm xlsx2json
# 4. Start local dev server.
pnpm dev pnpm dev
``` ```
After the startup is complete, open a browser and visit [http://localhost:5173](http://localhost:5173).
</details>
<details>
<summary><strong>Command Line Version</strong></summary>
<br />
```bash
node repl
```
</details>
## Other Version
<details>
<summary><strong>Versions</strong></summary>
<br />
- Cocos Ver: [gameall3d/LifeRestart_Cocos](https://github.com/gameall3d/LifeRestart_Cocos)
</details>
> More instructions at [documentation](https://liferestart.syaro.io/).
+40 -17
View File
@@ -6,6 +6,7 @@ import type { Log } from '@remake/hooks'
import { properties } from '@/display' import { properties } from '@/display'
import { achievements, events, talents } from '@remake/data' import { achievements, events, talents } from '@remake/data'
import { AutoInterval } from '@/config' import { AutoInterval } from '@/config'
import { format } from '@remake/vitex'
import './Play.css' import './Play.css'
function LogTalent({ id }: { id: number }) { function LogTalent({ id }: { id: number }) {
@@ -25,12 +26,19 @@ function LogTalents({ items }: { items: number[] }) {
return <ul className="log-inner log-talents">{els}</ul> return <ul className="log-inner log-talents">{els}</ul>
} }
const year = new Date().getFullYear()
interface LogEventProps { interface LogEventProps {
id: number id: number
post: boolean post: boolean
index: number
} }
function LogEvent({ id, post }: LogEventProps) { function LogEvent({ id, post, index }: LogEventProps) {
const { event, postEvent, grade } = events.get(id)! 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 ( return (
<> <>
<li className={`grade-${grade}`}>{event}</li> <li className={`grade-${grade}`}>{event}</li>
@@ -41,10 +49,10 @@ function LogEvent({ id, post }: LogEventProps) {
) )
} }
function LogEvents({ items }: { items: number[] }) { function LogEvents({ items, index }: { items: number[]; index: number }) {
const last = items.length - 1 const last = items.length - 1
const els = items.map((id, i) => ( const els = items.map((id, i) => (
<LogEvent key={id} id={id} post={i == last} /> <LogEvent key={id} id={id} post={i == last} index={index} />
)) ))
return <ul className="log-inner log-events">{els}</ul> return <ul className="log-inner log-events">{els}</ul>
} }
@@ -66,13 +74,13 @@ function LogAchievements({ items }: { items: number[] }) {
return <ul className="log-inner log-achievements">{els}</ul> return <ul className="log-inner log-achievements">{els}</ul>
} }
function Log({ log }: { log: Log }) { function Log({ log, index }: { log: Log; index: number }) {
return ( return (
<li className="log"> <li className="log">
<span className="age font-mono">{log.age}</span> <span className="age font-mono">{log.age}</span>
<div className="content"> <div className="content">
<LogTalents items={log.talents} /> <LogTalents items={log.talents} />
<LogEvents items={log.events} /> <LogEvents items={log.events} index={index} />
<LogAchievements items={log.achievements} /> <LogAchievements items={log.achievements} />
</div> </div>
</li> </li>
@@ -88,25 +96,40 @@ interface PropProps {
function Prop({ prop, value, grade }: PropProps) { function Prop({ prop, value, grade }: PropProps) {
const prevRef = useRef<number>(value) const prevRef = useRef<number>(value)
const [trend, setTrend] = useState<'up' | 'down' | 'normal'>('normal') const [trend, setTrend] = useState<'up' | 'down' | 'normal'>('normal')
const [animCount, setAnimCount] = useState<number>(0) const [flip, setFlip] = useState(0)
const [displayValue, setDisplayValue] = useState<number>(value)
useEffect(() => { useEffect(() => {
const prev = prevRef.current const prev = prevRef.current
if (value === prev) return if (value === prev) return
prevRef.current = value prevRef.current = value
setTrend(value > prev ? 'up' : 'down') setTrend(value > prev ? 'up' : 'down')
setAnimCount(c => c + 1) setFlip(f => (f + 1) % 2)
const timer = setTimeout(() => { let startTimestamp: number | null = null
setTrend('normal') const duration = 400
}, 2000) const startValue = displayValue
return () => clearTimeout(timer) 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]) }, [value])
return ( return (
<li <li className={`${prop} grade-${grade} trend-${trend}-${flip}`}>
className={`${prop} grade-${grade} trend-${trend}-${animCount % 2}`}
>
<span className="name">{properties[prop]}</span> <span className="name">{properties[prop]}</span>
<span className="value font-mono">{value}</span> <span className="value font-mono">{displayValue}</span>
</li> </li>
) )
} }
@@ -160,7 +183,7 @@ export function Play() {
ref={logRef} ref={logRef}
> >
{logs.map((log, index) => ( {logs.map((log, index) => (
<Log key={index} log={log} /> <Log key={index} log={log} index={index} />
))} ))}
</ul> </ul>
<div className="controls"> <div className="controls">
+9 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef, useMemo } from 'react'
import { usePicked, useEnd, useProfile } from '@remake/hooks' import { usePicked, useEnd, useProfile } from '@remake/hooks'
import { useEndJudge } from '@/hooks/judge' import { useEndJudge } from '@/hooks/judge'
import { properties, judgeDisplay } from '@/display' import { properties, judgeDisplay } from '@/display'
@@ -27,26 +27,24 @@ interface TalentListProps {
} }
function TalentList({ picked, picker }: TalentListProps) { function TalentList({ picked, picker }: TalentListProps) {
const [profile] = useProfile() const [profile] = useProfile()
const [talents, setTalents] = useState(usePicked()) const items = usePicked()
const talents = useMemo(() => {
if (!isDev || !profile.locked) return items
return new Set([...items, ...profile.locked])
}, [])
const hasInitialized = useRef(false) const hasInitialized = useRef(false)
useEffect(() => { useEffect(() => {
if (!profile.locked) return if (!profile.locked) return
let currentTalents = talents
if (isDev) {
currentTalents = new Set([...talents, ...profile.locked])
setTalents(currentTalents)
}
if (!hasInitialized.current) { if (!hasInitialized.current) {
for (const id of profile.locked) { for (const id of profile.locked) {
if (currentTalents.has(id) && !picked.has(id)) { if (talents.has(id) && !picked.has(id)) {
picker(id) picker(id)
} }
} }
hasInitialized.current = true hasInitialized.current = true
} }
}, [profile, talents, picked, picker]) }, [picked, picker])
return ( return (
<ul className="talent-list"> <ul className="talent-list">
{Array.from(talents, id => ( {Array.from(talents, id => (
-17
View File
@@ -1,17 +0,0 @@
## 流程
重开
初始化
抽天赋(包含继承的天赋)
天赋选择(检测天赋冲突)
天赋替换(检测天赋冲突)
点数分配(天赋额外点数)
游戏循环
年龄增加一岁
天赋触发
事件触发(从对应年龄事件池随机事件)
判断是否结束(生命值小于 1 结束)
总评
天赋继承
存档数据
重回首页
-13
View File
@@ -188,16 +188,3 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
return false return false
} }
} }
export function extractMaxTriggers(condition: string): number {
// Assuming only age related talents can be triggered multiple times.
const RE_AGE_CONDITION = /AGE\?\[([0-9,]+)\]/
const matchObject = RE_AGE_CONDITION.exec(condition)
if (matchObject === null) {
// Not age related, single trigger.
return 1
}
return matchObject[1]?.split(',')?.length ?? 1
}
+1
View File
@@ -17,6 +17,7 @@ describe('Achievement', () => {
intelligence: 5, intelligence: 5,
strength: 5, strength: 5,
money: 5, money: 5,
spirit: 5,
} }
test('trigger [times:500]', () => { test('trigger [times:500]', () => {
+1
View File
@@ -16,6 +16,7 @@ describe('Event', () => {
intelligence: 10, intelligence: 10,
strength: 0, strength: 0,
money: 0, money: 0,
spirit: 5,
} }
test('branch 10003:0 [玉佩]', () => { test('branch 10003:0 [玉佩]', () => {
+1
View File
@@ -17,6 +17,7 @@ describe('Achievement', () => {
intelligence: 5, intelligence: 5,
strength: 5, strength: 5,
money: 5, money: 5,
spirit: 5,
} }
test('talentsPicked', () => { test('talentsPicked', () => {
+16 -7
View File
@@ -2,7 +2,7 @@ import { expect, test, describe } from 'bun:test'
import { parse, type ConditionNode } from '@remake/condition' import { parse, type ConditionNode } from '@remake/condition'
import { achievements, events, talents } from '@remake/data' import { achievements, events, talents } from '@remake/data'
import type { GameState, ProfileState, Properties } from './state' import type { FlatState, GameState, ProfileState, Properties } from './state'
import { createFlatState, createHLProperties, propsEffect } from './state' import { createFlatState, createHLProperties, propsEffect } from './state'
import { SupportedFlatStateKeys } from './state' import { SupportedFlatStateKeys } from './state'
import { enableMapSet } from 'immer' import { enableMapSet } from 'immer'
@@ -11,7 +11,10 @@ enableMapSet()
/** /**
* 核心递归辅助函数:从多维语法树节点中提取所有属性名 * 核心递归辅助函数:从多维语法树节点中提取所有属性名
*/ */
function extractKeysFromNode(node: ConditionNode, properties: Set<string>) { function extractKeysFromNode(
node: ConditionNode,
properties: Set<keyof FlatState>,
) {
if (Array.isArray(node)) { if (Array.isArray(node)) {
for (const subNode of node) { for (const subNode of node) {
extractKeysFromNode(subNode, properties) extractKeysFromNode(subNode, properties)
@@ -22,7 +25,7 @@ function extractKeysFromNode(node: ConditionNode, properties: Set<string>) {
// 匹配操作符(><!?=)前面的属性名部分 // 匹配操作符(><!?=)前面的属性名部分
const match = node.match(/^([^><!?=]+)/) const match = node.match(/^([^><!?=]+)/)
if (match && match) { if (match && match) {
properties.add(match[1]!.trim()) properties.add(match[1]!.trim() as keyof FlatState)
} }
} }
} }
@@ -30,9 +33,9 @@ function extractKeysFromNode(node: ConditionNode, properties: Set<string>) {
/** /**
* 从条件字符串中提取所有使用到的属性键名 * 从条件字符串中提取所有使用到的属性键名
*/ */
function getPropertiesFromCondition(condition: string): Set<string> { function getPropertiesFromCondition(condition: string): Set<keyof FlatState> {
const parsedConditions = parse(condition) const parsedConditions = parse(condition)
const properties = new Set<string>() const properties = new Set<keyof FlatState>()
extractKeysFromNode(parsedConditions, properties) extractKeysFromNode(parsedConditions, properties)
return properties return properties
} }
@@ -205,7 +208,13 @@ describe('State', () => {
) )
}) })
const allocation = { charm: 5, intelligence: 5, strength: 5, money: 5 } const allocation = {
charm: 5,
intelligence: 5,
strength: 5,
money: 5,
spirit: 5,
}
test('createHLProperties', () => { test('createHLProperties', () => {
const props = createHLProperties(allocation) const props = createHLProperties(allocation)
expect(props.current.charm).toBe(allocation.charm) expect(props.current.charm).toBe(allocation.charm)
@@ -213,7 +222,7 @@ describe('State', () => {
expect(props.current.strength).toBe(allocation.strength) expect(props.current.strength).toBe(allocation.strength)
expect(props.current.money).toBe(allocation.money) expect(props.current.money).toBe(allocation.money)
expect(props.current.age).toBe(-1) expect(props.current.age).toBe(-1)
expect(props.current.spirit).toBe(0) expect(props.current.spirit).toBe(5)
}) })
test('propsEffect', () => { test('propsEffect', () => {
+2 -2
View File
@@ -30,11 +30,11 @@ describe('Talent', () => {
test('pull external', () => { test('pull external', () => {
const p = produce(profile, draft => { const p = produce(profile, draft => {
draft.lockedTalent = 1001 draft.locked = [1001]
}) })
const result = pull(options, p) const result = pull(options, p)
expect(result.length).toBe(options.count) expect(result.length).toBe(options.count)
expect(result).toContain(p.lockedTalent!) expect(result).toContain(p.locked![0]!)
}) })
test('pull rate', () => { test('pull rate', () => {
+9
View File
@@ -0,0 +1,9 @@
## Bugs
### 复活Bug
age 400
-> 寿元终
-> 天赋复活
age 401
-> 无可随机事件
-> 报错
+2
View File
@@ -47,6 +47,8 @@ export type Event = {
readonly exclude?: string readonly exclude?: string
/** 分支路线 */ /** 分支路线 */
readonly branch?: EventBranch[] readonly branch?: EventBranch[]
/** 是否需要格式化 */
readonly format?: boolean
} }
// @vt-types-end // @vt-types-end
Binary file not shown.
+15
View File
@@ -43,3 +43,18 @@ export function keys<T extends object, K extends keyof T = never>(
const filterSet = new Set<keyof T>(filter) const filterSet = new Set<keyof T>(filter)
return allKeys.filter(key => !filterSet.has(key)) as Exclude<keyof T, K>[] return allKeys.filter(key => !filterSet.has(key)) as Exclude<keyof T, K>[]
} }
// 定义获取字段值的方法签名
export type GetValueFn = (
fieldName: string,
) => string | number | boolean | null | undefined
export function format(str: string, getValueFn: GetValueFn): string {
if (!str) return ''
return str.replace(
/\{([^}]+)\}/g,
(match: string, fieldName: string): string => {
const value = getValueFn(fieldName)
if (value === undefined || value === null) return match
return String(value)
},
)
}