add: character

This commit is contained in:
Vick Scarlet
2026-08-16 20:06:16 +08:00
committed by 神戸小鳥
parent 46a8528864
commit afb4276dd1
18 changed files with 194 additions and 79 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Talent } from '@remake/data/talent'
import { type Talent, talents } from '@remake/data'
import TalentComponent from './Talent'
import talents from '@remake/data/talent'
import './Replaced.css'
export interface TalentProps {
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Talent } from '@remake/data/talent'
import talents from '@remake/data/talent'
import { type Talent, talents } from '@remake/data'
import './Talent.css'
export interface TalentProps {
+9
View File
@@ -27,6 +27,11 @@ 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 PullRateBase: Config['pull']['rate']['base'] = new Map([
[0, 889],
@@ -113,6 +118,10 @@ export const config: Config = {
additions: PullRateAdditions,
},
},
chara: {
count: CharacterPullCount,
knife: CharacterWeightKnife,
},
}
export default config
+2 -3
View File
@@ -1,10 +1,9 @@
import { useState, useRef } from 'react'
import { useLayoutEffect, useEffect } from 'react'
import { useNext, useGotoSummary } from '@remake/hooks'
import { useNext, useGotoSummary, type Log } from '@remake/hooks'
import { useJudge } from '@/hooks/judge'
import type { Log } from '@remake/hooks'
import { properties } from '@/display'
import { achievements, events, talents } from '@remake/data'
import { properties } from '@/display'
import { AutoInterval } from '@/config'
import { format } from '@remake/vitex'
import './Play.css'
+2 -4
View File
@@ -16,16 +16,14 @@ if (localStorage.getItem('version') !== '3.0.0') {
localStorage.getItem('extendTalent') || 'null',
)
if (lockedTalents) profile.locked = [lockedTalents]
const unique = JSON.parse(
localStorage.getItem('uniqueWaTaShi') || 'null',
)
if (unique) profile.unique = unique
localStorage.setItem('profile', JSON.stringify(profile))
localStorage.removeItem('times')
localStorage.removeItem('ACHV')
localStorage.removeItem('AEVT')
localStorage.removeItem('ATLT')
localStorage.removeItem('extendTalent')
const unique = localStorage.getItem('uniqueWaTaShi')
if (unique) localStorage.setItem('unique', unique)
localStorage.removeItem('uniqueWaTaShi')
}
localStorage.setItem('version', '3.0.0')
+61
View File
@@ -0,0 +1,61 @@
import { expect, test, describe } from 'bun:test'
import { pullCharacter, 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 = pullCharacter(
{ 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 = pullCharacter({ 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 = pullCharacter({ 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 = pullCharacter({ 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)
console.debug(unique)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { type Character, type Talent, characters, talents } from '@remake/data'
import { type RNG, type WeightItem, pick, pickWeight } from '@remake/vitex'
export type UniqueChara = Omit<Character, 'id' | 'name'>
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 UniqueChara
}
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 pullCharacter(
opt: PullCharaOpt,
tms: PullCharaTms,
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)]),
)
}
+3 -5
View File
@@ -1,11 +1,9 @@
import type { Event } from '@remake/data/event'
import events from '@remake/data/event'
import type { Properties } from './state'
import type { GameState, ProfileState } from './state'
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 { produce } from 'immer'
import type { TriggerResult } from './game'
import { produce } from 'immer'
export function check(
event: Event['id'],
+2 -2
View File
@@ -1,6 +1,6 @@
import { expect, test, describe } from 'bun:test'
import { createState, propsEffect, summary as stateSummary } from './state'
import { talentsPicked, start, next, summary, end } from './game'
import { pick, start, next, summary, end } from './game'
import { produce } from 'immer'
import { enableMapSet } from 'immer'
enableMapSet()
@@ -22,7 +22,7 @@ describe('Achievement', () => {
test('talentsPicked', () => {
// 挑战者、阴间福袋、轮盘赌
const result = talentsPicked([1122, 1145, 1146])
const result = pick([1122, 1145, 1146])
expect(result.talents.chains.size).toBeGreaterThan(1)
expect(result.additionalPoints.source).toContainEqual({
talent: 1122,
+4 -12
View File
@@ -1,11 +1,9 @@
import type { Achievement, Event, Talent } from '@remake/data'
import { ages, AchievementOpportunity as Ao } from '@remake/data'
import type { Allocation, GameState, ProfileState } from './state'
import type { Properties } from './state'
import type { GameState, ProfileState } from './state'
import { createState, nextProfile, propsEffect } from './state'
import { summary as stateSummary } from './state'
import type { PullOptions, ReplacementResult } from './talent'
import type { AdditionalPoint, AdditionalPoints } from './talent'
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'
@@ -19,14 +17,11 @@ export interface TriggerResult<T> {
state: GameState
triggers: T[]
}
export interface TalentsPickedResult {
export interface PickResult {
talents: ReplacementResult
additionalPoints: AdditionalPoints
}
export function talentsPicked(
talents: Iterable<Talent['id']>,
rng?: RNG,
): TalentsPickedResult {
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 }
@@ -110,6 +105,3 @@ export function end(
}
export { pull, exclude }
export type { GameState, ProfileState, Properties, RNG }
export type { PullOptions, ReplacementResult, Allocation }
export type { AdditionalPoint, AdditionalPoints }
+10 -1
View File
@@ -1 +1,10 @@
export * from './game'
export type { RNG } from '@remake/vitex'
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 { UniqueChara, UniqueGenCfg } from './character'
export type { PickResult, StartResult } from './game'
export type { NextResult, SummaryResult, EndResult } from './game'
export { pull, exclude } from './talent'
export { pick, start, next, summary, end } from './game'
+1 -5
View File
@@ -1,4 +1,4 @@
import type { Achievement, Event, Talent, Character } from '@remake/data'
import type { Achievement, Event, Talent } from '@remake/data'
import { produce } from 'immer'
import { sum, keys } from '@remake/vitex'
@@ -37,9 +37,6 @@ export interface GameState {
talentTriggers: Map<Talent['id'], number> // 本局天赋触发次数
}
/** 唯一角色 */
export type UniqueCharacter = Omit<Character, 'id' | 'name'>
/** 持久化存储的数据 */
export interface ProfileState {
times: number // 游戏次数
@@ -49,7 +46,6 @@ export interface ProfileState {
achievements: Set<Achievement['id']> // 达成的成就
highest?: Properties // 历史最高属性
lowest?: Properties // 历史最低属性
unique?: UniqueCharacter // 唯一角色属性
}
export function createState(
+2 -4
View File
@@ -1,7 +1,5 @@
import type { Talent, TalentGrade } from '@remake/data'
import { talents } from '@remake/data'
import type { Properties } from './state'
import type { GameState, ProfileState } from './state'
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'
+2 -4
View File
@@ -1,10 +1,8 @@
import { useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useReplaced } from './talent'
import { useConfig, useReplaced } from '.'
import type { Allocation } from '@remake/core'
import type { RNG } from '@remake/vitex'
import { keys, shuffle, random as frandom } from '@remake/vitex'
import { keys, shuffle, random as frandom, type RNG } from '@remake/vitex'
export type UserAllocation = Omit<Allocation, 'spirit'>
const init: UserAllocation = {
+5 -4
View File
@@ -1,7 +1,6 @@
import { useCallback } from 'react'
import { atom, useSetAtom, useAtomValue } from 'jotai'
import type { PullOptions } from '@remake/core'
import type { PullOptions, PullCharaOpt } from '@remake/core'
export interface Config {
/** 游戏锁定天赋数量 */
lock: number
@@ -9,8 +8,6 @@ export interface Config {
max: number
/** 游戏最少选择天赋数量 */
min: number
/** 天赋抽取个数 */
pull: PullOptions
/** 初始属性点 */
points: number
/** 单项属性点最大限制 */
@@ -19,6 +16,10 @@ export interface Config {
spirit: number
/** 模式选择限制 */
mode: number
/** 天赋抽取个数 */
pull: PullOptions
/** 名人模式配置 */
chara: PullCharaOpt
}
export const configAtom = atom<Config | null>(null)
+3 -5
View File
@@ -1,12 +1,10 @@
import { useCallback, useRef, useState } from 'react'
import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { useReplaced, useTalentReset } from './talent'
import { useAlloc, useAllocReset } from './alloc'
import { useConfig, useProfile, useReplaced, useAlloc } from '.'
import { useTalentReset, useAllocReset } from '.'
import { start, next, summary, end } from '@remake/core'
import type { GameState, Properties, NextResult } from '@remake/core'
import type { Talent } from '@remake/data/talent'
import type { Talent } from '@remake/data'
export enum Step {
Idle = 'idle',
+13 -24
View File
@@ -1,14 +1,12 @@
import { useState, useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { useSetStep } from './play'
import { pull, exclude, talentsPicked } from '@remake/core'
import type { TalentsPickedResult, RNG } from '@remake/core'
import type { Talent } from '@remake/data/talent'
import { useConfig, useProfile, useSetStep } from '.'
import { pull, exclude, pick, type PickResult } from '@remake/core'
import type { Talent } from '@remake/data'
import type { RNG } from '@remake/vitex'
export const pickedAtom = atom(new Set<Talent['id']>())
export const replacedAtom = atom<TalentsPickedResult | null>(null)
export const replacedAtom = atom<PickResult | null>(null)
export const useTalentReset = () => {
const setPicked = useSetAtom(pickedAtom)
@@ -41,25 +39,16 @@ export const useTalentPuller = () => {
return [pulled, puller] as const
}
export type TalentPickerResult =
| {
type: 'ok'
talent?: never
}
| {
type: 'not-enough'
talent?: never
}
| {
type: 'exclude'
talent: Talent['id']
}
export type PickOk = { type: 'ok'; talent?: never }
export type PickNe = { type: 'ne'; talent?: never }
export type PickEx = { type: 'ex'; talent: Talent['id'] }
export type PickerResult = PickOk | PickNe | PickEx
export const useTalentPicker = () => {
const { max } = useConfig()
const [picked, setPicked] = useAtom(pickedAtom)
const picker = useCallback(
(talent: Talent['id']): TalentPickerResult => {
(talent: Talent['id']): PickerResult => {
if (!picked) {
setPicked(new Set([talent]))
return { type: 'ok' }
@@ -70,9 +59,9 @@ export const useTalentPicker = () => {
setPicked(next)
return { type: 'ok' }
}
if (picked.size >= max) return { type: 'not-enough' }
if (picked.size >= max) return { type: 'ne' }
const e = exclude(talent, picked)
if (e) return { type: 'exclude', talent: e }
if (e) return { type: 'ex', talent: e }
const next = new Set([...picked, talent])
setPicked(next)
return { type: 'ok' }
@@ -97,7 +86,7 @@ export const useTalentSubmit = () => {
return useCallback(
(rng?: RNG) => {
if (!enabled) throw new Error('Not enough talents picked')
setReplaced(talentsPicked(picked, rng))
setReplaced(pick(picked, rng))
setStep(Step.Alloc)
},
[enabled, picked, setReplaced, setStep],
+2 -2
View File
@@ -1,5 +1,5 @@
export type RNG = (max?: number, min?: number) => number
export function random(max: number, min: number = 0, rng?: RNG): number {
export function random(max: number = 1, min: number = 0, rng?: RNG): number {
if (rng) return rng(max, min)
return Math.floor(Math.random() * (max - min + 1)) + min
}
@@ -7,7 +7,7 @@ export function pick<T>(items: T[], rng?: RNG) {
if (items.length === 0) return null
return items[random(items.length - 1, 0, rng)] ?? null
}
export type WeightItem<T> = [T, number]
export type WeightItem<T> = readonly [T, number]
export function pickWeight<T>(items: WeightItem<T>[], rng?: RNG) {
if (items.length === 0) return null
const totalWeight = items.reduce((sum, [, weight]) => sum + weight, 0)