update: character allocation

This commit is contained in:
Vick Scarlet
2026-08-15 18:16:38 +08:00
parent 10debc6515
commit a2e40230c7
10 changed files with 149 additions and 81 deletions
+6 -2
View File
@@ -80,8 +80,8 @@
> span { > span {
width: 100%; width: 100%;
height: calc(2rem * var(--ratio)); height: calc(2rem * var(--ratio));
color: var(--base-background-color); color: var(--grade-color);
background: var(--grade-color); background: var(--base-background-color);
border: 0.0625rem solid var(--grade-color); border: 0.0625rem solid var(--grade-color);
border-bottom: none; border-bottom: none;
display: flex; display: flex;
@@ -91,6 +91,10 @@
font-weight: bold; font-weight: bold;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
> span.name {
color: var(--base-background-color);
background: var(--grade-color);
}
} }
> li.left { > li.left {
--left-color: var(--color-error); --left-color: var(--color-error);
+32 -15
View File
@@ -1,10 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks' import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks'
import { usePicked, useReplaced, useStart } from '@remake/hooks' import { usePicked, useReplaced, useStart, useIsClassic } from '@remake/hooks'
import type { AdditionalPoint } from '@remake/hooks' import type { AdditionalPoint } from '@remake/hooks'
import { properties } from '@/display' import { properties } from '@/display'
import { keys } from '@remake/vitex' import { keys } from '@remake/vitex'
import { BasePoints, judgeGradeByValue } from '@/config' import { judgeGradeByValue } from '@/config'
import { talents } from '@remake/data' import { talents } from '@remake/data'
import Replaced from '@/components/Replaced' import Replaced from '@/components/Replaced'
import './Alloc.css' import './Alloc.css'
@@ -30,15 +30,17 @@ function AllocInput(props: AllocInputProps) {
} }
interface PointsDetailProps { interface PointsDetailProps {
base: number
source: AdditionalPoint[] source: AdditionalPoint[]
fixed?: boolean
} }
function PointsDetail({ source }: PointsDetailProps) { function PointsDetail({ base, source, fixed }: PointsDetailProps) {
return ( return (
<ul className="points-detail"> <ul className="points-detail">
<li> <li>
<span></span> <span>{fixed ? '固定' : '基础'}</span>
<span className="font-mono">{BasePoints}</span> <span className="font-mono">{base}</span>
</li> </li>
{source.map(({ talent, points }) => ( {source.map(({ talent, points }) => (
<li key={talent}> <li key={talent}>
@@ -51,18 +53,19 @@ function PointsDetail({ source }: PointsDetailProps) {
} }
export function Alloc() { export function Alloc() {
const isClassic = useIsClassic()
const picked = usePicked() const picked = usePicked()
const { const {
talents: { chains }, talents: { chains },
additionalPoints: { source }, additionalPoints: { source },
} = useReplaced() } = useReplaced()
const left = useLeftPoints() const { base, left } = useLeftPoints()
const [allocation, allocator] = useAllocator() const [{ alloc, final, base: ba }, allocator] = useAllocator()
const random = usePointRandomizer() const random = usePointRandomizer()
const start = useStart() const start = useStart()
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false)
const handleNext = () => { const handleNext = () => {
if (left) { if (left != 0) {
// TODO: Show a warning that there are still points left // TODO: Show a warning that there are still points left
return return
} }
@@ -79,25 +82,39 @@ export function Alloc() {
</li> </li>
))} ))}
</ul> </ul>
<ul className="alloc"> <ul className={`alloc ${isClassic ? 'classic' : 'modify'}`}>
<li className={`left left-${left}`}> <li className={`left left-${left}`}>
<span></span> <span className="name"></span>
<button <button
className="font-mono" className="font-mono"
onClick={() => setShowDetail(!showDetail)} onClick={() => setShowDetail(!showDetail)}
> >
{left} {left}
</button> </button>
{showDetail && <PointsDetail source={source} />} {showDetail && (
<PointsDetail
base={base}
source={source}
fixed={!isClassic}
/>
)}
</li> </li>
{keys(allocation).map(key => ( {keys(alloc).map(key => (
<li <li
key={key} key={key}
className={`property grade-${judgeGradeByValue(key, allocation[key])}`} className={`property grade-${judgeGradeByValue(key, final[key])}`}
> >
<span>{properties[key]}</span> <span className="name">
{properties[key]}
{!isClassic && (
<span className="font-mono">[{ba[key]}]</span>
)}
</span>
{!isClassic && (
<span className="font-mono">{final[key]}</span>
)}
<AllocInput <AllocInput
point={allocation[key]} point={alloc[key]}
onChange={value => allocator(key, value)} onChange={value => allocator(key, value)}
/> />
</li> </li>
+4 -4
View File
@@ -1,5 +1,5 @@
import { useCharaPuller, useCharaPicker, convertProps } from '@remake/hooks' import { useCharaPuller, useCharaPicker, useCharaSubmit } from '@remake/hooks'
import { useUnique, useUniqueGenerator, useCharaStart } from '@remake/hooks' import { useUnique, useUniqueGenerator, convertProps } from '@remake/hooks'
import type { BaseChara } from '@remake/hooks' import type { BaseChara } from '@remake/hooks'
import { TalentComponent } from '@/components/Talent' import { TalentComponent } from '@/components/Talent'
import { judgeGradeByValue } from '@/config' import { judgeGradeByValue } from '@/config'
@@ -88,7 +88,7 @@ export function Chara() {
const u = useUnique() const u = useUnique()
const [{ unique, characters }, puller] = useCharaPuller() const [{ unique, characters }, puller] = useCharaPuller()
const [picked, picker] = useCharaPicker() const [picked, picker] = useCharaPicker()
const start = useCharaStart() const submit = useCharaSubmit()
const ready = picked && (picked.type === 'unique' ? !!u : true) const ready = picked && (picked.type === 'unique' ? !!u : true)
return ( return (
<div className="screen chara"> <div className="screen chara">
@@ -114,7 +114,7 @@ export function Chara() {
</button> </button>
<button <button
className={ready ? 'primary' : 'error'} className={ready ? 'primary' : 'error'}
onClick={ready ? () => start(picked!) : undefined} onClick={ready ? () => submit(picked!) : undefined}
> >
</button> </button>
+7 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { usePicked, useEnd, useProfile } from '@remake/hooks' import { usePicked, useEnd, useProfile, useIsClassic } from '@remake/hooks'
import { useEndJudge } from '@/hooks/judge' import { useEndJudge } from '@/hooks/judge'
import { properties, judgeDisplay } from '@/display' import { properties, judgeDisplay } from '@/display'
import TalentComponent from '@/components/Talent' import TalentComponent from '@/components/Talent'
@@ -52,6 +52,7 @@ function TalentList({ picked, picker }: TalentListProps) {
} }
export default function Summary() { export default function Summary() {
const isClassic = useIsClassic()
const [locked, picker, end] = useEnd() const [locked, picker, end] = useEnd()
const handleEnd = () => { const handleEnd = () => {
const achievements = end() const achievements = end()
@@ -62,7 +63,11 @@ export default function Summary() {
<div className="screen summary"> <div className="screen summary">
<Judges /> <Judges />
<div className="section"> <div className="section">
<div className="title"></div> <div className="title">
{isClassic
? '你可以锁定一个天赋,下辈子还能抽到'
: '名人天赋不可锁定'}
</div>
<TalentList picked={locked} picker={picker} /> <TalentList picked={locked} picker={picker} />
</div> </div>
<button className="primary" onClick={handleEnd}> <button className="primary" onClick={handleEnd}>
-2
View File
@@ -68,14 +68,12 @@ button {
outline: none; outline: none;
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
border: var(--style-background) 0.0625rem solid;
background: var(--style-color); background: var(--style-color);
color: var(--style-background); color: var(--style-background);
} }
} }
&:active { &:active {
border: var(--style-background) 0.0625rem solid;
background: var(--style-color); background: var(--style-color);
color: var(--style-background); color: var(--style-background);
transition: none; transition: none;
+6 -13
View File
@@ -82,24 +82,17 @@ export function charaPropToBaseAlloc(props: CharacterProperty): BaseAlloc {
} }
} }
function charaPropToAlloc( function convert(chara: BaseChara) {
props: CharacterProperty,
spirit: number,
): Allocation {
return { ...charaPropToBaseAlloc(props), spirit }
}
function convert(chara: BaseChara, spirit: number) {
return { return {
allocation: charaPropToAlloc(chara.property, spirit), allocation: charaPropToBaseAlloc(chara.property),
talents: chara.talent, talents: chara.talent,
} }
} }
export function startChara(id: Character['id'], spirit: number) { export function startChara(id: Character['id']) {
return convert(characters.get(id)!, spirit) return convert(characters.get(id)!)
} }
export function startUnique(chara: BaseChara, spirit: number) { export function startUnique(chara: BaseChara) {
return convert(chara, spirit) return convert(chara)
} }
+60 -22
View File
@@ -1,8 +1,9 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig, useReplaced } from '.' import { useConfig, useReplaced, useIsClassic } from '.'
import type { Allocation } from '@remake/core' import type { Allocation } from '@remake/core'
import { keys, shuffle, random as frandom, type RNG } from '@remake/vitex' import { keys, shuffle, zoneFit, random } from '@remake/vitex'
import type { RNG } from '@remake/vitex'
export type UserAllocation = Omit<Allocation, 'spirit'> export type UserAllocation = Omit<Allocation, 'spirit'>
const init: UserAllocation = { const init: UserAllocation = {
@@ -11,50 +12,86 @@ const init: UserAllocation = {
strength: 0, strength: 0,
money: 0, money: 0,
} }
export const baseAllocAtom = atom<UserAllocation>({ ...init })
export const allocAtom = atom<UserAllocation>({ ...init }) export const allocAtom = atom<UserAllocation>({ ...init })
const alloced = (alloc: UserAllocation) => const alloced = (alloc: UserAllocation) =>
Object.values(alloc).reduce((a, b) => a + b, 0) Object.values(alloc).reduce((a, b) => a + b, 0)
export const useAllocReset = () => { export const useAllocReset = () => {
const setBaseAlloc = useSetAtom(baseAllocAtom)
const setAlloc = useSetAtom(allocAtom) const setAlloc = useSetAtom(allocAtom)
return useCallback(() => setAlloc({ ...init }), [setAlloc]) return useCallback(() => {
setBaseAlloc({ ...init })
setAlloc({ ...init })
}, [setBaseAlloc, setAlloc])
}
export const useBaseAlloc = () => useAtomValue(baseAllocAtom)
export const useAlloc = () => {
const base = useAtomValue(baseAllocAtom)
const alloc = useAtomValue(allocAtom)
const final = {
charm: base.charm + alloc.charm,
intelligence: base.intelligence + alloc.intelligence,
strength: base.strength + alloc.strength,
money: base.money + alloc.money,
}
return { base, alloc, final }
} }
export const useAlloc = () => useAtomValue(allocAtom)
export const usePoints = () => { export const usePoints = () => {
const { points } = useConfig() const isClassic = useIsClassic()
const { additionalPoints } = useReplaced() const { additionalPoints } = useReplaced()
return Math.max(points + additionalPoints.points, 0) const additional = additionalPoints.points
if (!isClassic) {
const base = useAtomValue(baseAllocAtom)
return { base: alloced(base), total: additional, additional }
}
const { points } = useConfig()
return { base: points, total: points + additional, additional }
} }
export const useLeftPoints = () => { export const useLeftPoints = () => {
const points = usePoints() const points = usePoints()
const alloc = useAtomValue(allocAtom) const alloc = useAtomValue(allocAtom)
return points - alloced(alloc) const ca = alloced(alloc)
const left = points.total - ca
return { ...points, left, alloced: ca }
}
export const useAllocBefore = () => {
const { allocate } = useConfig()
const { total } = usePoints()
const isClassic = useIsClassic()
const isPositive = total >= 0
const limit = isClassic
? allocate
: Math.min(Math.ceil(Math.abs(total) / 2), allocate) *
(isPositive ? 1 : -1)
return { total, limit, isPositive, isClassic }
} }
export const useAllocator = () => { export const useAllocator = () => {
const { allocate } = useConfig() const { total, limit } = useAllocBefore()
const total = usePoints() const alloc = useAlloc()
const [alloc, setAlloc] = useAtom(allocAtom) const setAlloc = useSetAtom(allocAtom)
const allocator = useCallback( const allocator = useCallback(
(key: keyof UserAllocation, value: number) => { (key: keyof UserAllocation, value: number) => {
setAlloc(prev => { setAlloc(prev => {
const left = total - alloced(prev) + prev[key] const last = prev[key]
const max = Math.min(left, allocate) const left = total - alloced(prev) + last
const final = Math.max(Math.min(max, value), 0) const max = zoneFit(left, [0, limit])
if (prev[key] === final) return prev const final = zoneFit(value, [0, max])
if (last === final) return prev
return { ...prev, [key]: final } return { ...prev, [key]: final }
}) })
}, },
[allocate, total, setAlloc], [limit, total, setAlloc],
) )
return [alloc, allocator] as const return [alloc, allocator] as const
} }
export const usePointRandomizer = () => { export const usePointRandomizer = () => {
const { allocate } = useConfig() const { total, limit } = useAllocBefore()
const total = usePoints()
const setAlloc = useSetAtom(allocAtom) const setAlloc = useSetAtom(allocAtom)
return useCallback( return useCallback(
(rng?: RNG) => { (rng?: RNG) => {
@@ -63,15 +100,16 @@ export const usePointRandomizer = () => {
const alloc = { ...init } const alloc = { ...init }
while (suffled.length) { while (suffled.length) {
const key = suffled.pop()! const key = suffled.pop()!
const max = Math.min(allocate, left) const n = suffled.length
const min = Math.max(0, left - suffled.length * allocate) const max = zoneFit(limit, [0, left])
const value = frandom(max, min, rng) const min = zoneFit(left - n * limit, [0, limit])
const value = random(max, min, rng)
alloc[key] = value alloc[key] = value
left -= value left -= value
} }
setAlloc(alloc) setAlloc(alloc)
}, },
[allocate, total, setAlloc], [total, limit, setAlloc],
) )
} }
+14 -18
View File
@@ -1,8 +1,9 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig, useProfile, useSetStep, useSetGameState, Step } from '.' import { pickedAtom, replacedAtom, baseAllocAtom } from '.'
import { useConfig, useProfile, useSetStep, Step } from '.'
import { uniqueGenerate, startUnique, startChara } from '@remake/core' import { uniqueGenerate, startUnique, startChara } from '@remake/core'
import { pullChara, start, pick } from '@remake/core' import { pullChara, pick } from '@remake/core'
import type { BaseChara, PullCharaTms } from '@remake/core' import type { BaseChara, PullCharaTms } from '@remake/core'
import type { Character } from '@remake/data' import type { Character } from '@remake/data'
import type { RNG } from '@remake/vitex' import type { RNG } from '@remake/vitex'
@@ -70,34 +71,29 @@ export const useCharaPicker = () => {
return [picked, { chara, unique: uni }] as const return [picked, { chara, unique: uni }] as const
} }
export const useCharaStart = () => { export const useCharaSubmit = () => {
const { spirit } = useConfig()
const [profile] = useProfile() const [profile] = useProfile()
const unique = useAtomValue(uniqueAtom) const unique = useAtomValue(uniqueAtom)
const setStep = useSetStep() const setStep = useSetStep()
const setState = useSetGameState() const setPicked = useSetAtom(pickedAtom)
const setReplaced = useSetAtom(replacedAtom)
const setBaseAlloc = useSetAtom(baseAllocAtom)
return useCallback( return useCallback(
(picked: CharaPick, rng?: RNG) => { (picked: CharaPick, rng?: RNG) => {
let result let result
if (picked.type === 'unique') { if (picked.type === 'unique') {
if (!unique) if (!unique)
throw new Error('Unique character not generated yet') throw new Error('Unique character not generated yet')
result = startUnique(unique, spirit) result = startUnique(unique)
} else { } else {
result = startChara(picked.id, spirit) result = startChara(picked.id)
} }
const { talents, additionalPoints } = pick(result.talents, rng) setPicked(new Set(result.talents))
// TODO: additionalPoints setReplaced(pick(result.talents, rng))
const { state, achievements } = start( setBaseAlloc(result.allocation)
profile, setStep(Step.Alloc)
result.allocation,
talents.talents,
)
setState(state)
setStep(Step.Play)
return achievements
}, },
[spirit, profile, unique, setStep, setState], [profile, unique, setPicked, setReplaced, setStep],
) )
} }
+11 -3
View File
@@ -52,6 +52,8 @@ export const useGameReset = () => {
}, [resetTalent, resetAlloc, resetState, resetLogs]) }, [resetTalent, resetAlloc, resetState, resetLogs])
} }
export const useMode = () => useAtomValue(modeAtom)
export const useIsClassic = () => useAtomValue(modeAtom) === Mode.Classic
export const useStep = () => useAtomValue(stepAtom) export const useStep = () => useAtomValue(stepAtom)
export const useSetStep = () => useSetAtom(stepAtom) export const useSetStep = () => useSetAtom(stepAtom)
export const useGameState = () => useAtomValue(gameStateAtom) export const useGameState = () => useAtomValue(gameStateAtom)
@@ -82,7 +84,7 @@ export const useModeChoose = () => {
case Mode.Celebrity: return setStep(Step.Chara) case Mode.Celebrity: return setStep(Step.Chara)
} }
}, },
[setStep], [setMode, setStep],
) )
return [Mode, choose] as const return [Mode, choose] as const
} }
@@ -92,7 +94,7 @@ export const useStart = () => {
const [profile] = useProfile() const [profile] = useProfile()
const setStep = useSetAtom(stepAtom) const setStep = useSetAtom(stepAtom)
const setState = useSetAtom(gameStateAtom) const setState = useSetAtom(gameStateAtom)
const allocate = useAlloc() const { final: allocate } = useAlloc()
const { talents: tr } = useReplaced() const { talents: tr } = useReplaced()
return useCallback(() => { return useCallback(() => {
const alloc = { ...allocate, spirit } const alloc = { ...allocate, spirit }
@@ -148,10 +150,12 @@ export const useEnd = () => {
const setStep = useSetAtom(stepAtom) const setStep = useSetAtom(stepAtom)
const state = useAtomValue(gameStateAtom) const state = useAtomValue(gameStateAtom)
const reset = useGameReset() const reset = useGameReset()
const isClassic = useIsClassic()
const [locked, setLocked] = useState<Set<Talent['id']>>(new Set()) const [locked, setLocked] = useState<Set<Talent['id']>>(new Set())
const picker = useCallback( const picker = useCallback(
(talent: Talent['id']) => { (talent: Talent['id']) => {
setLocked(prev => { setLocked(prev => {
if (!isClassic) return prev
if (prev.has(talent)) { if (prev.has(talent)) {
const next = new Set(prev) const next = new Set(prev)
next.delete(talent) next.delete(talent)
@@ -169,7 +173,11 @@ export const useEnd = () => {
const ender = useCallback(() => { const ender = useCallback(() => {
if (!state) if (!state)
throw new Error('Game state is not available or already ended.') throw new Error('Game state is not available or already ended.')
const l = locked.size > 0 ? Array.from(locked) : undefined const l = isClassic
? locked.size > 0
? Array.from(locked)
: undefined
: profile.locked
const result = end(state, profile, l) const result = end(state, profile, l)
setStep(Step.Idle) setStep(Step.Idle)
setProfile(result.profile) setProfile(result.profile)
+9
View File
@@ -1,6 +1,7 @@
export type RNG = (max?: number, min?: number) => number export type RNG = (max?: number, min?: number) => number
export function random(max: number = 1, min: number = 0, rng?: RNG): number { export function random(max: number = 1, min: number = 0, rng?: RNG): number {
if (rng) return rng(max, min) if (rng) return rng(max, min)
if (max < min) [max, min] = [min, max]
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
export function pick<T>(items: T[], rng?: RNG) { export function pick<T>(items: T[], rng?: RNG) {
@@ -58,3 +59,11 @@ export function format(str: string, getValueFn: GetValueFn): string {
}, },
) )
} }
export function zoneFit(value: number, zone: [number, number]) {
const max = Math.max(zone[0], zone[1])
if (value > max) return max
const min = Math.min(zone[0], zone[1])
if (value < min) return min
return value
}