update: hooks, web

This commit is contained in:
Vick Scarlet
2026-08-16 20:06:16 +08:00
committed by 神戸小鳥
parent 2f91709d1e
commit 322f775796
67 changed files with 2339 additions and 2243 deletions
+19 -11
View File
@@ -6,24 +6,32 @@ permissions:
contents: write
jobs:
build-and-deploy:
concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession.
concurrency: ci-${{ github.ref }}
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
- name: 🛎️ Checkout
uses: actions/checkout@v4
- name: Install pnpm
- name: 🛠️ Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: 🛠️ Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
version: latest
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
run: |
pnpm install
pnpm xlsx2json
pnpm build
- name: 📦 Install Dependencies
run: pnpm install
- name: Deploy 🚀
- name: 🏗️ Build data
run: bun build:data
- name: 🏗️ Build web
run: bun build:web
- name: 🚀 Deploy
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: template # The folder the action should deploy.
folder: apps/web/dist # The folder the action should deploy.
+1 -1
View File
@@ -5,7 +5,7 @@
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"start": "bun run src/index.ts",
"lint": "eslint .",
"lint": "bunx --bun eslint .",
"test": "bun test"
},
"dependencies": {
+1 -29
View File
@@ -1,32 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"paths": {
"@/*": ["./src/*"],
},
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+3 -2
View File
@@ -5,10 +5,11 @@
<meta name="description" content="人生重开模拟器 - 这垃圾人生一秒也不想待了"/>
<meta name="keywords" content="人生重开模拟器 liferestart life restart remake 人生重来"/>
<meta name="color-scheme" content="light dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>人生重开模拟器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
<div id="remake"></div>
<script type="module" src="src/index.tsx"></script>
</body>
</html>
+17 -10
View File
@@ -4,24 +4,31 @@
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint",
"test": "vitest"
"dev": "bunx --bun vite",
"build": "bunx --bun vite build",
"preview": "bunx --bun vite preview",
"lint": "bunx --bun eslint",
"test": "bunx --bun vitest"
},
"dependencies": {
"@remake/core": "workspace:*",
"@remake/data": "workspace:*",
"preact": "^10.26.9"
"@remake/hooks": "workspace:*",
"@remake/vitex": "workspace:*",
"classnames": "^2.5.1",
"jotai": "^2.20.2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"eslint-config-preact": "^2.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint-config-react": "^1.1.7",
"jotai-devtools": "^0.14.0",
"vite": "^8.1.3",
"vitest": "^4.1.10"
},
"eslintConfig": {
"extends": "preact"
"extends": "react"
}
}
+12
View File
@@ -0,0 +1,12 @@
.screen {
min-width: 32rem;
max-width: calc(100dvw - 2rem);
position: relative;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> * { width: 100%; }
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
import { useInit } from '@/hooks/storage'
import { useStep, Step } from '@remake/hooks'
import Home from '@/containers/Home'
import Pick from '@/containers/TalentPick'
import Alloc from '@/containers/Alloc'
import Play from '@/containers/Play'
import Summary from '@/containers/Summary'
import { Loading } from './containers/Loading'
import './Game.css'
export function Game() {
const [inited, init] = useInit()
const step = useStep()
useEffect(() => {
if (!inited) init()
}, [inited])
if (!inited) return <Loading />
switch (step) {
case Step.Idle:
return <Home />
case Step.Mode:
case Step.Pick:
return <Pick />
case Step.Alloc:
return <Alloc />
case Step.Play:
return <Play />
case Step.Summary:
return <Summary />
default:
return null
}
}
export default Game
+50
View File
@@ -0,0 +1,50 @@
ul.replaced {
display: flex;
flex-direction: column;
gap: 0.5rem;
li {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0;
.talent {
width: 100%;
}
}
li:not(:first-child) {
position: relative;
&::before {
content: '';
position: absolute;
display: block;
box-sizing: border-box;
border: 0.0625rem solid var(--base-text-color);
border-left: none;
right: 0;
top: -1rem;
margin-right: -1rem;
width: 0.625rem;
height: 1.5rem;
}
&::after {
content: '';
position: absolute;
display: block;
box-sizing: border-box;
border: 0.0625rem solid var(--base-text-color);
border-right: none;
border-bottom: none;
right: 0;
top: 0.5rem;
margin-right: -0.6875rem;
margin-top: -0.03125rem;
width: 0.375rem;
height: 0.375rem;
transform: rotate(-45deg);
transform-origin: 0 0;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Talent } from '@remake/data/talent'
import TalentComponent from './Talent'
import talents from '@remake/data/talent'
import './Replaced.css'
export interface TalentProps {
id: Talent['id']
chains?: Talent['id'][]
}
export function Replaced({ id, chains }: TalentProps) {
return (
<ul className="replaced">
<li>
<TalentComponent id={id} selected={true} />
</li>
{chains?.map(id => {
const talent = talents.get(id)
if (!talent) return null
return (
<li key={id}>
<TalentComponent id={id} selected={false} />
</li>
)
})}
</ul>
)
}
export default Replaced
+41
View File
@@ -0,0 +1,41 @@
.talent {
--color: var(--grade-color);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
line-height: 2rem;
gap: 1rem;
color: var(--color);
background: var(--background, var(--base-background-color));
cursor: pointer;
user-select: none;
border: 0.0625rem solid var(--grade-color);
transition: all 0.2s ease-in-out;
&.talent-grade-0 { --grade-color: var(--color-grade-0); }
&.talent-grade-1 { --grade-color: var(--color-grade-1); }
&.talent-grade-2 { --grade-color: var(--color-grade-2); }
&.talent-grade-3 { --grade-color: var(--color-grade-3); }
&.selected {
--color: var(--base-background-color);
--background: var(--grade-color);
&:hover { --background: oklch(from var(--grade-color) calc(l + 0.1) c h); }
}
&:hover { --background: oklch(from var(--grade-color) calc(l + 0.5) c h); }
.talent-description {
color: oklch(from var(--color) calc(l + 0.1) c h);
font-size: 0.6rem;
align-self: flex-end;
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{
&: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);}
.talent-description { color: oklch(from var(--color) calc(l - 0.2) c h); }
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Talent } from '@remake/data/talent'
import talents from '@remake/data/talent'
import cx from 'classnames'
import './Talent.css'
export interface TalentProps {
id: Talent['id']
selected: boolean
}
export function TalentComponent({ id, selected }: TalentProps) {
const talent = talents.get(id)
if (!talent) return null
const grade = talent?.grade ?? 0
return (
<div className={cx('talent', `talent-grade-${grade}`, { selected })}>
<span className="talent-name">{talent.name}</span>
<span className="talent-description">{talent.description}</span>
</div>
)
}
export default TalentComponent
+46
View File
@@ -0,0 +1,46 @@
.theme-toggle-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 50%;
position: fixed;
top: 0.5rem;
right: 0.5rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.3s;
--hover-color: rgba(0, 0, 0, 0.05);
&:hover { background-color: var(--hover-color);}
}
.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 { fill: oklch(from var(--base-text-color) calc(l + 0.2) c h); }
.sun-beams { stroke: oklch(from var(--base-text-color) calc(l + 0.2) c h); }
}
html[data-theme='dark'] {
.theme-toggle-btn { --hover-color: rgba(255, 255, 255, 0.1); }
.mask-cutter-group { transform: translate(0, 0); }
.theme-svg { transform: rotate(375deg); }
.sun-center {
transform: scale(1.5);
fill: #fbbf24;
}
.sun-beams {
opacity: 0;
transform: scale(0.6);
stroke: #fbbf24;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react'
import './ThemeToggle.css'
export function ThemeToggle() {
const [isDark, setIsDark] = useState(
() => matchMedia('(prefers-color-scheme: dark)').matches,
)
useEffect(() => {
document.documentElement.dataset.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)"
/>
<g className="sun-beams">
<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
+60
View File
@@ -0,0 +1,60 @@
import type { Config } from '@remake/hooks'
import type { TalentGrade } from '@remake/data/talent'
function gadm(grade: TalentGrade, value: number) {
return new Map([[grade, { mode: 'multiply', value }]])
}
export const AdditonMap = {
times: [
{ value: 0, additions: new Map() },
{ value: 10, additions: gadm(2, 2) },
{ value: 30, additions: gadm(2, 3) },
{ value: 50, additions: gadm(2, 4) },
{ value: 70, additions: gadm(2, 5) },
{ value: 100, additions: gadm(2, 6) },
],
achievements: [
{ value: 0, additions: new Map() },
{ value: 10, additions: gadm(3, 2) },
{ value: 30, additions: gadm(3, 3) },
{ value: 50, additions: gadm(3, 4) },
{ value: 70, additions: gadm(3, 5) },
{ value: 100, additions: gadm(3, 6) },
],
}
export const BasePoints = 20
export const config: Config = {
mode: 10,
pick: 3,
points: BasePoints,
allocate: 10,
pull: {
count: 10,
rate: {
base: new Map([
[0, 889],
[1, 100],
[2, 10],
[3, 1],
]),
additions: {
times: value => {
for (const item of AdditonMap.times.reverse())
if (value >= item.value) return item.additions
return new Map()
},
achievements: value => {
const size = value.size
for (const item of AdditonMap.achievements.reverse())
if (size >= item.value) return item.additions
return new Map()
},
},
},
},
}
export default config
+112
View File
@@ -0,0 +1,112 @@
.screen.point-allocation {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
width: 100%;
.allocation-input {
display: flex;
gap: 0;
border: 0.0625rem solid var(--base-text-color);
box-sizing: border-box;
width: 100%;
button {
padding: 0;
margin: 0;
width: 2rem;
height: 2rem;
border: none;
text-align: center;
}
input {
flex: 1;
height: 2rem;
width: 2rem;
}
}
.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;
li {
display: flex;
justify-content: space-between;
line-height: 2rem;
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%;
> li {
flex: 1 0;
display: flex;
position: relative;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
> span {
width: 100%;
height: 2rem;
border: 0.0625rem solid var(--base-text-color);
border-bottom: none;
text-align: center;
line-height: 2rem;
box-sizing: border-box;
}
}
> li.left {
> span {
background: var(--color-primary);
color: var(--base-background-color);
border: 0.0625rem solid var(--color-primary);
border-bottom: none;
}
> button {
flex: 1;
font-size: 1rem;
line-height: 2rem;
width: 6rem;
padding: 0;
box-sizing: border-box;
}
}
}
> div {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
gap: 0.5rem;
button { flex: 1 0; }
}
> ul.talent-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
li { width: 100%; }
}
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react'
import { useAllocator, usePointRandomizer, useLeftPoints } from '@remake/hooks'
import { usePicked, useReplaced } from '@remake/hooks'
import type { AdditionalPoint } from '@remake/hooks'
import { properties } from '@/display'
import { keys } from '@remake/vitex'
import { BasePoints } from '@/config'
import { talents } from '@remake/data'
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 {
source: AdditionalPoint[]
}
function PointsDetail({ source }: PointsDetailProps) {
return (
<ul className="points-detail">
<li>
<span></span>
<span className="font-mono">{BasePoints}</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 picked = usePicked()
const {
talents: { chains },
additionalPoints: { source },
} = useReplaced()
const left = useLeftPoints()
const [allocation, allocator] = useAllocator()
const random = usePointRandomizer()
const [showDetail, setShowDetail] = useState(false)
const handleNext = () => {
// navigate('/life')
}
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">
<li className="left">
<span></span>
<button
className="primary font-mono"
onClick={() => setShowDetail(!showDetail)}
>
{left}
</button>
{showDetail && <PointsDetail source={source} />}
</li>
{keys(allocation).map(key => (
<li key={key}>
<span>{properties[key]}</span>
<AllocInput
point={allocation[key]}
onChange={value => allocator(key, value)}
/>
</li>
))}
</ul>
<div>
<button className="info" onClick={() => random()}>
</button>
<button className="primary" onClick={() => {}}>
</button>
</div>
</div>
)
}
export default Alloc
+17
View File
@@ -0,0 +1,17 @@
.screen.home {
gap: 5rem;
.title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
h1 {
font-size: 4rem;
margin: 0;
}
h2 {
font-size: 2rem;
margin: 0;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import { useRemake } from '@remake/hooks'
import './Home.css'
export default function Home() {
const remake = useRemake()
return (
<div className="screen home">
<div className="title">
<h1></h1>
<h2></h2>
</div>
<button className="primary" onClick={remake}>
</button>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
export function Loading() {
return (
<div className="screen loading">
<h2>...</h2>
</div>
)
}
export default Loading
+3
View File
@@ -0,0 +1,3 @@
export default function Life() {
return <></>
}
+10
View File
@@ -0,0 +1,10 @@
export default function Summary() {
return (
<div className="screen summary">
<h2></h2>
<p>: {}</p>
{/* 展现解锁的成就等 */}
<button onClick={() => {}}></button>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
.screen.talent-pick {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1rem;
width: 100%;
ul.talent-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
li {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0;
.talent {
width: 100%;
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
// import { useEffect } from 'react'
import { useTalentPuller, useTalentPicker } from '@remake/hooks'
import { useTalentSubmit, useSubmitIsEnable } from '@remake/hooks'
import TalentComponent from '@/components/Talent'
import './TalentPick.css'
export default function TalentPick() {
const [pulled, puller] = useTalentPuller()
const [talents, picker] = useTalentPicker()
const [enabled, limit] = useSubmitIsEnable()
const submit = useTalentSubmit()
// useEffect(puller, [])
if (!pulled)
return (
<div className="screen talent-pick">
<button className="primary" onClick={() => puller()}>
</button>
</div>
)
return (
<div className="screen talent-pick">
<ul className="talent-list">
{pulled.map(id => (
<li key={id} onClick={() => picker(id)}>
<TalentComponent id={id} selected={talents.has(id)} />
</li>
))}
</ul>
{enabled ? (
<button className="primary" onClick={() => submit()}>
</button>
) : (
<button className="error"> {limit} </button>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
export const properties = {
charm: '颜值',
intelligence: '智力',
strength: '体质',
money: '家境',
}
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useTransition } from 'react'
import { atom, useAtom } from 'jotai'
import { useConfigInject, useProfile, useProfileInject } from '@remake/hooks'
import { get, set } from '@/storage'
import { config } from '@/config'
const initedAtom = atom(false)
export const useInit = () => {
const configInject = useConfigInject()
const profileInject = useProfileInject()
const [inited, setInited] = useAtom(initedAtom)
const [_, startTransition] = useTransition()
const loader = useCallback(() => {
configInject(config)
startTransition(async () => {
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 || []),
})
setInited(true)
})
}, [])
return [inited, loader] as const
}
export const useSaver = () => {
const [profile] = useProfile()
const saver = useCallback(() => {
const str = JSON.stringify({
times: profile.times,
achievements: Array.from(profile.achievements),
events: Array.from(profile.events),
talents: Array.from(profile.talents),
})
set({ profile: str })
}, [profile])
return saver
}
+16 -5
View File
@@ -1,6 +1,17 @@
import { render } from 'preact'
export function App() {
return <div>Hello</div>
}
import { createRoot } from 'react-dom/client'
import { StrictMode } from 'react'
import { Game } from './Game'
import { ThemeToggle } from './components/ThemeToggle'
import { Jotai } from './jotai'
import './styles/colors.css'
import './styles/common.css'
render(<App />, document.getElementById('app')!)
const container = document.getElementById('remake')!
const root = createRoot(container)
root.render(
<StrictMode>
<Jotai />
<ThemeToggle />
<Game />
</StrictMode>,
)
+6
View File
@@ -0,0 +1,6 @@
import { useAtomsDevtools } from 'jotai-devtools/utils'
export function Jotai() {
useAtomsDevtools('Store')
return null
}
export default Jotai
+12
View File
@@ -0,0 +1,12 @@
export async function get<Key extends string>(keys: Key[]) {
return Object.fromEntries(keys.map(k => [k, localStorage.getItem(k)])) as {
[K in Key]: string | null
}
}
export async function set<Key extends string>(data: Record<Key, string>) {
for (const key in data) {
localStorage.setItem(key, data[key])
}
return true
}
+14
View File
@@ -0,0 +1,14 @@
:root {
color-scheme: light dark;
--base-text-color: light-dark(#121314, #f9fafb);
--base-background-color: light-dark(#f7f7f7, #121314);
--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-primary: light-dark(#61b700, #b9ff69);
--color-info: light-dark(#454ad3, #b5b7ff);
--color-error: light-dark(#e42b2b, #ff6969);
}
+89
View File
@@ -0,0 +1,89 @@
html { transition: all 0.2s ease-in-out; }
html[data-theme='light'] { color-scheme: light; }
html[data-theme='dark'] { color-scheme: dark; }
:root { color: var(--base-text-color); }
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);
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: 1.2rem;
padding: 0 1rem;
line-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
user-select: none;
transition: all 0.2s ease-in-out;
&:hover {
--background-color: var(--style-color);
--text-color: var(--style-background);
}
&.primary { --style-color: var(--color-primary); }
&.info { --style-color: var(--color-info); }
&.error { --style-color: var(--color-error); }
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
input {
font-size: 1rem;
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; }
}
@media (max-width: 32rem) { :root { font-size: 2.8vw; } }
+4 -30
View File
@@ -1,39 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["preact"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"jsxImportSource": "preact",
"types": ["react", "vite/client"],
"jsxImportSource": "react",
"paths": {
"@/*": ["./src/*"],
"@assets/*": ["./assets/*"],
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"include": ["node_modules/vite/client.d.ts", "**/*"]
"include": ["**/*"]
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
plugins: [react()],
resolve: { tsconfigPaths: true },
})
+6 -4
View File
@@ -9,23 +9,25 @@
"build:data": "bun --filter @remake/data build",
"preview": "bun --filter @remake/web preview",
"start": "bun --filter @remake/console start",
"lint": "eslint",
"lint": "bunx --bun eslint",
"lint:console": "bun --filter @remake/console lint",
"lint:web": "bun --filter @remake/web lint",
"lint:core": "bun --filter @remake/core lint",
"lint:data": "bun --filter @remake/data lint",
"lint:condition": "bun --filter @remake/condition lint",
"lint:hooks": "bun --filter @remake/hooks lint",
"test": "bun --filter @remake/web test",
"test:console": "bun --filter @remake/console test",
"test:web": "bun --filter @remake/web test",
"test:core": "bun --filter @remake/core test",
"test:data": "bun --filter @remake/data test",
"test:condition": "bun --filter @remake/condition test"
"test:condition": "bun --filter @remake/condition test",
"test:hooks": "bun --filter @remake/hooks test"
},
"devDependencies": {
"@types/bun": "1.3.14",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.66.0",
"@typescript/native": "npm:typescript@^7.0.2",
"eslint": "^10.8.0",
"typescript": "npm:@typescript/typescript6@^6.0.2"
+7 -9
View File
@@ -13,9 +13,7 @@ export function parse(condition: string): ConditionTree {
const catchString = (i: number): void => {
const str = condition.substring(cursor, i).trim()
cursor = i
if (str) {
stack[0]?.push(str)
}
if (str) stack[0]?.push(str)
}
for (let i = 0; i < length; i++) {
@@ -118,13 +116,13 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
switch (symbol) {
case '>':
return propData > conditionData
return (propData ?? 0) > conditionData
case '<':
return propData < conditionData
return (propData ?? 0) < conditionData
case '>=':
return propData >= conditionData
return (propData ?? 0) >= conditionData
case '<=':
return propData <= conditionData
return (propData ?? 0) <= conditionData
case '=':
if (propData instanceof Set) {
return propData.has(conditionData)
@@ -132,7 +130,7 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
if (Array.isArray(propData)) {
return propData.includes(conditionData)
}
return propData == conditionData
return (propData ?? 0) == conditionData
case '!=':
if (propData instanceof Set) {
return !propData.has(conditionData)
@@ -140,7 +138,7 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
if (Array.isArray(propData)) {
return !propData.includes(conditionData)
}
return propData != conditionData
return (propData ?? 0) != conditionData
case '?': // 🌟 包含判定符(如 属性值 是否在 [1,2,3] 数组范围内)
if (propData instanceof Set) {
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+13
View File
@@ -3,6 +3,7 @@
"type": "module",
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"main": "src/index.ts",
"scripts": {
"lint": "eslint .",
"test": "bun test"
@@ -15,5 +16,17 @@
},
"eslintConfig": {
"extends": "../../package.json"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
}
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
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()
@@ -20,7 +21,11 @@ describe('Achievement', () => {
test('trigger [times:500]', () => {
const p = { ...profile, times: 500 }
const result = trigger('END', createState(allocation, []), p)
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) // 所有人都是我
+4 -7
View File
@@ -1,13 +1,10 @@
import type {
Achievement,
AchievementOpportunity,
} from '@remake/data/achievement'
import achievements from '@remake/data/achievement'
import type { GameState, ProfileState } from '@/state'
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'
import type { TriggerResult } from './game'
const OpportunityMap = Map.groupBy(
achievements.keys(),
+5 -5
View File
@@ -1,11 +1,11 @@
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 { propsEffect, createFlatState } from '@/state'
import type { Properties } from './state'
import type { 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 type { TriggerResult } from './game'
export function check(
event: Event['id'],
@@ -30,7 +30,7 @@ export function trigger(
draft.events.add(eventId)
if (!effect) return
if (effect.LIF) draft.life += effect.LIF
const pe = {} as Partial<Properties>
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
+26 -39
View File
@@ -1,26 +1,23 @@
import ages from '@remake/data/age'
import { propsEffect } from '@/state'
import { enableMapSet, produce } from 'immer'
import type { Achievement, Event, Talent } from '@remake/data'
import { ages, AchievementOpportunity as Ao } from '@remake/data'
import type { Allocation, 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 { 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 type * as achievement from '@/achievement'
export type * as event from '@/event'
export type * as talent from '@/talent'
export type * as state from '@/state'
import type { GameState, ProfileState } from '@/state'
export type { GameState, ProfileState }
export interface TriggerResult<T> {
state: GameState
triggers: T[]
}
export { pull } from '@/talent'
// export { summary, nextProfile } from '@/state'
import { replacement, additionalPoints } from '@/talent'
import type { ReplacementResult, AdditionalPoints } from '@/talent'
import type { RNG } from '@remake/vitex'
export interface TalentsPickedResult {
talents: ReplacementResult
additionalPoints: AdditionalPoints
@@ -34,8 +31,6 @@ export function talentsPicked(
return { talents: r, additionalPoints: ap }
}
import { createState } from '@/state'
export interface StartResult {
state: GameState
achievements: Achievement['id'][]
@@ -45,19 +40,9 @@ export function start(
...args: Parameters<typeof createState>
): StartResult {
const state = createState(...args)
const ar = achievementTrigger('START', state, profile)
const ar = atr(Ao.Start, state, profile)
return { state: ar.state, achievements: ar.triggers }
}
import type { Achievement } from '@remake/data/achievement'
import type { Event } from '@remake/data/event'
import type { Talent } from '@remake/data/talent'
import { trigger as achievementTrigger } from '@/achievement'
import { trigger as eventTrigger } from '@/event'
import { trigger as talentTrigger } from '@/talent'
import { check as eventCheck } from '@/event'
import { pickWeight } from '@remake/vitex'
export interface NextResult {
state: GameState
age: number
@@ -76,13 +61,13 @@ export function next(
draft.props = propsEffect(state.props, { age: 1 })
})
const age = s.props.current.age
const tr = talentTrigger(s, profile, rng)
const tr = ttr(s, profile, rng)
const events = ages
.get(age)!
.event.filter(([e]) => eventCheck(e, tr.state, profile))
.event.filter(([e]) => ec(e, tr.state, profile))
const event = pickWeight(events, rng)!
const er = eventTrigger(event, tr.state, profile)
const ar = achievementTrigger('TRAJECTORY', er.state, profile)
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,
@@ -94,8 +79,6 @@ export function next(
}
}
import { summary as stateSummary } from '@/state'
export interface SummaryResult {
state: GameState
summary: number
@@ -105,12 +88,11 @@ export function summary(
state: GameState,
profile: ProfileState,
): SummaryResult {
const ar = achievementTrigger('SUMMARY', state, profile)
const ar = atr(Ao.Summary, state, profile)
const s = stateSummary(ar.state)
return { state: ar.state, summary: s, achievements: ar.triggers }
}
import { nextProfile } from '@/state'
export interface EndResult {
profile: ProfileState
achievements: Achievement['id'][]
@@ -121,7 +103,12 @@ export function end(
profile: ProfileState,
lockedTalent?: Talent['id'],
) {
const ar = achievementTrigger('END', state, profile)
const ar = atr(Ao.End, state, profile)
const p = nextProfile(profile, ar.state, lockedTalent)
return { profile: p, achievements: ar.triggers }
}
export { pull, exclude }
export type { GameState, ProfileState, Allocation, RNG }
export type { PullOptions, ReplacementResult }
export type { AdditionalPoint, AdditionalPoints }
+1 -1
View File
@@ -1 +1 @@
export * from '@/game'
export * from './game'
+1 -4
View File
@@ -1,10 +1,7 @@
import { expect, test, describe } from 'bun:test'
import { parse, type ConditionNode } from '@remake/condition'
import achievements from '@remake/data/achievement'
import events from '@remake/data/event'
import talents from '@remake/data/talent'
import { achievements, events, talents } from '@remake/data'
import type { GameState, ProfileState, Properties } from './state'
import { createFlatState, createHLProperties, propsEffect } from './state'
import { SupportedFlatStateKeys } from './state'
+7 -9
View File
@@ -1,8 +1,6 @@
import type { Talent } from '@remake/data/talent'
import type { Event } from '@remake/data/event'
import type { Achievement } from '@remake/data/achievement'
import type { Achievement, Event, Talent } from '@remake/data'
import { produce } from 'immer'
import { sum } from '@remake/vitex'
import { sum, keys } from '@remake/vitex'
/** 基础的属性 */
export interface Properties {
@@ -112,7 +110,7 @@ type FlatMapper<Key extends FlatStateKey> = (
state: FlatTarget,
) => FlatState[Key]
const FlatMappers = {
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,
@@ -139,9 +137,9 @@ const FlatMappers = {
ATLT: state => state.profile.talents,
AACH: state => state.profile.achievements,
SUM: ({ game }) => summary(game),
} as { [Key in FlatStateKey]: FlatMapper<Key> }
}
export const SupportedFlatStateKeys = new Set(Object.keys(FlatMappers))
export const SupportedFlatStateKeys = new Set(keys(FlatMappers))
const flatStateHandle = {
get<Key extends FlatStateKey>(target: FlatTarget, prop: Key) {
@@ -175,7 +173,7 @@ export function propsEffect(hlp: HLProperties, effect: Partial<Properties>) {
export function highestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of Object.keys(a) as (keyof Properties)[]) {
for (const key of keys(a)) {
result[key] = Math.max(a[key], b[key])
}
return result
@@ -184,7 +182,7 @@ export function highestProperties(a: Properties, b?: Properties): Properties {
export function lowestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of Object.keys(a) as (keyof Properties)[]) {
for (const key of keys(a)) {
result[key] = Math.min(a[key], b[key])
}
return result
+1 -2
View File
@@ -2,8 +2,7 @@ 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 { createState } from './state'
import talents from '@remake/data/talent'
import { talents } from '@remake/data'
import { produce } from 'immer'
import { enableMapSet } from 'immer'
enableMapSet()
+10 -10
View File
@@ -1,12 +1,12 @@
import type { Talent, TalentGrade } from '@remake/data/talent'
import talents from '@remake/data/talent'
import type { Properties } from '@/state'
import type { GameState, ProfileState } from '@/state'
import type { Talent, TalentGrade } from '@remake/data'
import { talents } from '@remake/data'
import type { Properties } from './state'
import type { 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'
import type { TriggerResult } from './game'
const Grades: TalentGrade[] = [0, 1, 2, 3] as const
const GradeMap = Map.groupBy(
@@ -16,10 +16,6 @@ const GradeMap = Map.groupBy(
export const count = talents.size
export function get(talent: number) {
return talents.get(talent)
}
export type PullRate = Map<TalentGrade, number>
export type PullRateAddition = {
mode: 'add' | 'multiply'
@@ -64,7 +60,11 @@ export interface PullOptions {
rate: RateOptions
}
export function pull(options: PullOptions, profile: ProfileState, rng?: RNG) {
export function pull(
options: PullOptions,
profile: ProfileState,
rng?: RNG,
): Talent['id'][] {
const rate = Array.from(
talentRateWithAddition(options.rate, profile).entries(),
)
+1 -29
View File
@@ -1,32 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"paths": {
"@/*": ["./src/*"],
},
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+5 -1
View File
@@ -5,11 +5,15 @@
"main": "src/index.ts",
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"build": "bunx --bun v-transform@3.0.1 transform -t ts -w src -d dist \"**/[!~$]*.xlsx\" ",
"build": "bunx --bun v-transform transform -t ts -w src -d dist \"**/[!~$]*.xlsx\" --map",
"lint": "eslint .",
"test": "bun test"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./*": {
"types": "./dist/*.ts",
"import": "./dist/*.ts"
+12 -6
View File
@@ -1,12 +1,18 @@
/** 成就稀有度 */
export type AchievementGrade = 0 | 1 | 2 | 3
export enum AchievementGrade {
White = 0,
Blue = 1,
Purple = 2,
Orange = 3,
}
/** 成就触发时机 */
export type AchievementOpportunity =
| 'START' // 分配完成点数,点击开始新人生后
| 'TRAJECTORY' // 每一年的人生经历中
| 'SUMMARY' // 人生结束,点击人生总结后
| 'END' // 游戏完成,点击重开 重开次数在这之后才会+1
export enum AchievementOpportunity {
Start = 'START', // 分配完成点数,点击开始新人生后
Trajectory = 'TRAJECTORY', // 每一年的人生经历中
Summary = 'SUMMARY', // 人生结束,点击人生总结后
End = 'END', // 游戏完成,点击重开 重开次数在这之后才会+1
}
/** 成就 */
export type Achievement = {
+12
View File
@@ -0,0 +1,12 @@
export * from '../dist/achievement'
export { default as achievements } from '../dist/achievement'
export * from '../dist/age'
export { default as ages } from '../dist/age'
export * from '../dist/character'
export { default as characters } from '../dist/character'
export * from '../dist/event'
export { default as events } from '../dist/event'
export * from '../dist/specialthanks'
export { default as specialThanks } from '../dist/specialthanks'
export * from '../dist/talent'
export { default as talents } from '../dist/talent'
+6 -1
View File
@@ -1,5 +1,10 @@
/** 天赋稀有度 */
export type TalentGrade = 0 | 1 | 2 | 3
export enum TalentGrade {
White = 0,
Blue = 1,
Purple = 2,
Orange = 3,
}
/** 天赋效果 */
export type TalentEffect = {
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./setup-tests.ts"] # 在运行测试前加载环境
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@remake/hooks",
"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/core": "workspace:*",
"@remake/data": "workspace:*",
"@remake/vitex": "workspace:*"
},
"peerDependencies": {
"jotai": "^2.20.2",
"react": "^19.2.8"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
"eslint-config-react": "^1.1.7"
},
"eslintConfig": {
"extends": "react"
},
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./*": {
"import": "./src/*.ts",
"types": "./src/*.ts"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator'
GlobalRegistrator.register()
import { afterEach, expect } from 'bun:test'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
// Optional: cleans up `render` after each test
afterEach(() => {
cleanup()
})
+69
View File
@@ -0,0 +1,69 @@
import { useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useReplaced } from './talent'
import type { Allocation } from '@remake/core'
import type { RNG } from '@remake/vitex'
import { keys, shuffle, random as frandom } from '@remake/vitex'
const init: Allocation = { charm: 0, intelligence: 0, strength: 0, money: 0 }
const allocAtom = atom<Allocation>(init)
const alloced = (alloc: Allocation) =>
Object.values(alloc).reduce((a, b) => a + b, 0)
export const usePoints = () => {
const { points } = useConfig()
const { additionalPoints } = useReplaced()
return Math.max(points + additionalPoints.points, 0)
}
export const useLeftPoints = () => {
const points = usePoints()
const alloc = useAtomValue(allocAtom)
return points - alloced(alloc)
}
export const useAllocator = () => {
const { allocate } = useConfig()
const total = usePoints()
const [alloc, setAlloc] = useAtom(allocAtom)
const allocator = useCallback(
(key: keyof Allocation, value: number) => {
setAlloc(prev => {
const left = total - alloced(prev) + prev[key]
const max = Math.min(left, allocate)
const final = Math.max(Math.min(max, value), 0)
if (prev[key] === final) return prev
return { ...prev, [key]: final }
})
},
[allocate, total],
)
return [alloc, allocator] as const
}
export const usePointRandomizer = () => {
const { allocate } = useConfig()
const total = usePoints()
const setAlloc = useSetAtom(allocAtom)
return useCallback(
(rng?: RNG) => {
const suffled = shuffle(keys(init), rng)
let left = total
const alloc = { ...init }
while (suffled.length) {
const key = suffled.pop()!
const max = Math.min(allocate, left)
const min = Math.max(0, left - suffled.length * allocate)
const value = frandom(max, min, rng)
alloc[key] = value
left -= value
}
setAlloc(alloc)
},
[allocate, total],
)
}
export const useAllocSubmit = () => {}
+24
View File
@@ -0,0 +1,24 @@
import { useCallback } from 'react'
import { atom, useSetAtom, useAtomValue } from 'jotai'
import type { PullOptions } from '@remake/core'
export interface Config {
pick: number
pull: PullOptions
points: number
allocate: number
mode: number
}
export const configAtom = atom<Config | null>(null)
export const useConfigInject = () => {
const setConfig = useSetAtom(configAtom)
return useCallback((config: Config) => setConfig(config), [setConfig])
}
export const useConfig = () => {
const config = useAtomValue(configAtom)
if (!config) throw new Error('Game config is not set')
return config
}
+6
View File
@@ -0,0 +1,6 @@
export * from './config'
export * from './profile'
export * from './talent'
export * from './play'
export * from './alloc'
export type * from '@remake/core'
+59
View File
@@ -0,0 +1,59 @@
import { useTransition, useCallback } from 'react'
import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { end } from '@remake/core'
import type { GameState } from '@remake/core'
import type { Talent } from '@remake/data/talent'
export enum Step {
Idle = 'idle',
Mode = 'mode',
Pick = 'pick',
Alloc = 'alloc',
Play = 'play',
Summary = 'summary',
}
const stepAtom = atom<Step>(Step.Idle)
const gameStateAtom = atom<GameState | null>(null)
export const useStep = () => useAtomValue(stepAtom)
export const useSetStep = () => useSetAtom(stepAtom)
export const useRemake = () => {
const config = useConfig()
const [profile] = useProfile()
const setStep = useSetAtom(stepAtom)
return useCallback(() => {
const step = profile.times < config.mode ? Step.Pick : Step.Mode
setStep(step)
}, [config, profile, setStep])
}
export const useEnd = () => {
const [profile, setProfile] = useProfile()
const [step, setStep] = useAtom(stepAtom)
const [state, setState] = useAtom(gameStateAtom)
const [pending, startTransition] = useTransition()
const ender = useCallback(
(talent?: Talent['id']) => {
startTransition(async () => {
if (!state || step === Step.Summary)
throw new Error(
'Game state is not available or already ended.',
)
const { profile: next, achievements } = end(
state,
profile,
talent,
)
setStep(Step.Idle)
setState(null)
setProfile(next)
})
},
[state, step, profile, setStep, setState, setProfile],
)
return [pending, ender] as const
}
+14
View File
@@ -0,0 +1,14 @@
import { describe, test, expect } from 'bun:test'
import { screen, renderHook } from '@testing-library/react'
import { useProfile, useProfileInject } from './profile'
describe('profile', () => {
test('inject', () => {
expect(() => renderHook(() => useProfile())).toThrow()
// const inject = renderHook(() => useProfileInject())
})
test('no inject throw', () => {
expect(() => renderHook(() => useProfile())).toThrow()
// const inject = renderHook(() => useProfileInject())
})
})
+19
View File
@@ -0,0 +1,19 @@
import { useCallback } from 'react'
import { atom, useAtom, useSetAtom } from 'jotai'
import type { ProfileState } from '@remake/core'
const profileAtom = atom<ProfileState | null>(null)
export const useProfile = () => {
const [profile, setProfile] = useAtom(profileAtom)
if (!profile) throw new Error('Profile is not loaded')
return [profile, setProfile] as const
}
export const useProfileInject = () => {
const setProfile = useSetAtom(profileAtom)
return useCallback(
(profile: ProfileState) => setProfile(profile),
[setProfile],
)
}
+100
View File
@@ -0,0 +1,100 @@
import { useState, useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { useSetStep, Step } from './play'
import { pull, exclude, talentsPicked } from '@remake/core'
import type { TalentsPickedResult, RNG } from '@remake/core'
import type { Talent } from '@remake/data/talent'
const pickedAtom = atom(new Set<Talent['id']>())
const replacedAtom = atom<TalentsPickedResult | null>(null)
export const usePicked = () => {
const picked = useAtomValue(pickedAtom)
if (!picked) throw new Error('No talents picked')
return picked
}
export const useReplaced = () => {
const replaced = useAtomValue(replacedAtom)
if (!replaced) throw new Error('No talents replaced')
return replaced
}
export const useTalentPuller = () => {
const { pull: p } = useConfig()
const [profile] = useProfile()
const [pulled, setPulled] = useState<Talent['id'][] | null>(null)
const puller = useCallback(
(rng?: RNG) => setPulled(pull(p, profile, rng)),
// (rng?: RNG) =>
// setPulled([
// 1142, 1143, 1144, 1145, 1146, 1086, 1122, 1111, 1130, 1048,
// ]),
[p, profile, setPulled],
)
return [pulled, puller] as const
}
export type TalentPickerResult =
| {
type: 'ok'
talent?: never
}
| {
type: 'not-enough'
talent?: never
}
| {
type: 'exclude'
talent: Talent['id']
}
export const useTalentPicker = () => {
const { pick } = useConfig()
const [picked, setPicked] = useAtom(pickedAtom)
const picker = useCallback(
(talent: Talent['id']): TalentPickerResult => {
if (!picked) {
setPicked(new Set([talent]))
return { type: 'ok' }
}
if (picked.has(talent)) {
const next = new Set(picked)
next.delete(talent)
setPicked(next)
return { type: 'ok' }
}
if (picked.size >= pick) return { type: 'not-enough' }
const e = exclude(talent, picked)
if (e) return { type: 'exclude', talent: e }
const next = new Set([...picked, talent])
setPicked(next)
return { type: 'ok' }
},
[pick, picked, setPicked],
)
return [picked, picker] as const
}
export const useSubmitIsEnable = () => {
const { pick } = useConfig()
const picked = useAtomValue(pickedAtom)
const enabled = picked.size >= pick
return [enabled, pick] as const
}
export const useTalentSubmit = () => {
const picked = useAtomValue(pickedAtom)
const setReplaced = useSetAtom(replacedAtom)
const [enabled] = useSubmitIsEnable()
const next = useSetStep()
return useCallback(
(rng?: RNG) => {
if (!enabled) throw new Error('Not enough talents picked')
setReplaced(talentsPicked(picked, rng))
next(Step.Alloc)
},
[enabled, picked, setReplaced, next],
)
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["react", "bun"],
},
"include": ["**/*"]
}
+15
View File
@@ -22,3 +22,18 @@ export function pickWeight<T>(items: WeightItem<T>[], rng?: RNG) {
export function sum(arr: number[]) {
return arr.reduce((sum, v) => sum + v, 0)
}
export function shuffle<T>(array: T[], rng?: RNG): T[] {
const result = [...array]
for (let i = result.length - 1; i > 0; i--) {
const j = random(i, 0, rng)
const temp = result[i]
result[i] = result[j]!
result[j] = temp!
}
return result
}
export function keys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]
}
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+883 -1924
View File
File diff suppressed because it is too large Load Diff
+1 -23
View File
@@ -1,29 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": [],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"include": ["**/*"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"skipLibCheck": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"exclude": ["**/node_modules", "**/dist", "**/build"]
}