add: toast achievement & message

This commit is contained in:
Vick Scarlet
2026-08-16 13:37:32 +08:00
parent aff50e2521
commit 9017780898
19 changed files with 329 additions and 25 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"@remake/vitex": "workspace:*",
"jotai": "^2.20.2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-hot-toast": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.18",
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useInit, useWatcher } from '@/hooks/storage'
import { useStep, Step } from '@remake/hooks'
import ToastContainer from '@/toast'
import Home from '@/containers/Home'
import Mode from '@/containers/Mode'
import Chara from '@/containers/Chara'
@@ -40,6 +41,6 @@ export function Game() {
const saving = useWatcher()
useEffect(() => { if (!inited) init() }, [inited, init])
if (!inited) return <Loading />
return <><Container /><Saving active={saving} /></>
return <><Container /><ToastContainer /><Saving active={saving} /></>
}
export default Game
+23
View File
@@ -0,0 +1,23 @@
.achievement-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
height: 2rem;
gap: 1rem;
color: var(--grade-color);
background: var(--background, var(--base-background-color));
cursor: pointer;
user-select: none;
border: 0.0625rem solid var(--grade-color);
box-sizing: border-box;
transition: all 0.2s ease-in-out;
.name { font-weight: bold; }
.description {
font-size: 0.85rem;
user-select: none;
&::before { content: '['; margin-right: 0.125rem; }
&::after { content: ']'; margin-left: 0.125rem; }
&::before, &::after { color: var(--grade-color); }
}
}
+19
View File
@@ -0,0 +1,19 @@
import { type Achievement as A, achievement } from '@remake/data'
import './Achievement.css'
export interface AchievementProps {
id: A['id']
}
export function Achievement({ id }: AchievementProps) {
const ach = achievement.get(id)
if (!ach) return null
const grade = ach?.grade ?? 0
return (
<div className={`achievement-item grade-${grade}`}>
<span className="name">{ach.name}</span>
<span className="description">{ach.description}</span>
</div>
)
}
export default Achievement
+10
View File
@@ -0,0 +1,10 @@
.message-item {
display: flex;
justify-content: center;
align-items: center;
height: 2rem;
border: 0.0625rem solid var(--base-text-color);
color: var(--base-text-color);
background: var(--base-background-color);
padding: 0 1rem;
}
+10
View File
@@ -0,0 +1,10 @@
import './Message.css'
export interface MsgToastProps {
message: React.ReactNode
}
export function MsgToast({ message }: MsgToastProps) {
return <div className="message-item">{message}</div>
}
export default MsgToast
+5 -1
View File
@@ -2,6 +2,7 @@ import { useAchv, useGoHome } from '@remake/hooks'
import { judge, judgeGrade, judgeGradeByValue } from '@/config'
import { rates } from '@/display'
import { achievements, type Achievement } from '@remake/data'
import { toastMsg } from '@/toast'
import './Achv.css'
interface AchievementProps {
@@ -22,6 +23,7 @@ export function AchievementItem({ id, colled }: AchievementProps) {
export function Achv() {
const goHome = useGoHome()
const showRank = () => toastMsg('别卷了,没有排行榜', 'achv-rank')
const [stats, sorted] = useAchv()
const jtimes = judge('times', stats.times)
const gtimes = judgeGrade('times', jtimes)
@@ -34,7 +36,9 @@ export function Achv() {
return (
<div className="screen achv">
<div className="controls">
<button className="secondary"></button>
<button className="secondary" onClick={showRank}>
</button>
<button className="primary" onClick={goHome}>
</button>
+3 -5
View File
@@ -6,6 +6,7 @@ import { properties } from '@/display'
import { keys } from '@remake/vitex'
import { judgeGradeByValue } from '@/config'
import { talents } from '@remake/data'
import { toastAchvs, toastMsg } from '@/toast'
import Replaced from '@/components/Replaced'
import './Alloc.css'
@@ -65,12 +66,9 @@ export function Alloc() {
const start = useStart()
const [showDetail, setShowDetail] = useState(false)
const handleNext = () => {
if (left != 0) {
// TODO: Show a warning that there are still points left
return
}
if (left != 0) return toastMsg('还有剩余点数未分配', 'alloc-toast')
const achievements = start()
// TODO: Show achievements
toastAchvs(achievements)
console.log('Achievements', achievements)
}
return (
+7 -1
View File
@@ -5,6 +5,7 @@ import { judgeGradeByValue } from '@/config'
import { characters } from '@remake/data'
import { properties } from '@/display'
import { keys } from '@remake/vitex'
import { toastMsg } from '@/toast'
import Talent from '@/components/Talent'
import './Chara.css'
@@ -89,6 +90,11 @@ export function Chara() {
const [{ unique, characters }, puller] = useCharaPuller()
const [picked, picker] = useCharaPicker()
const submit = useCharaSubmit()
const handleSubmit = () => {
if (ready) return submit(picked)
if (!picked) toastMsg('请先选择角色', 'chara-toast-pick')
else toastMsg('请先生成唯一角色', 'chara-toast-unique')
}
const ready = picked && (picked.type === 'unique' ? !!u : true)
return (
<div className="screen chara">
@@ -114,7 +120,7 @@ export function Chara() {
</button>
<button
className={ready ? 'primary' : 'error'}
onClick={ready ? () => submit(picked!) : undefined}
onClick={handleSubmit}
>
</button>
+1 -2
View File
@@ -14,8 +14,7 @@
width: auto;
display: flex;
gap: 1rem;
>.github-btn,
>.theme-toggle-btn {
>button {
width: 2.5rem;
height: 2.5rem;
padding: 0.25rem;
+14 -10
View File
@@ -1,6 +1,7 @@
import { useTalentPuller, useTalentPicker } from '@remake/hooks'
import { useTalentSubmit, useSubmitIsEnable } from '@remake/hooks'
import { PullCount, dev } from '@/config'
import { toastMsg } from '@/toast'
import Talent from '@/components/Talent'
import './Pick.css'
@@ -9,7 +10,13 @@ export function Pick() {
const [talents, picker] = useTalentPicker()
const { enabled, min, max } = useSubmitIsEnable()
const submit = useTalentSubmit()
// useEffect(puller, [])
const msg = enabled
? '下一步'
: `请选取 ${min == max ? min : `${min}~${max}`} 个天赋`
const handleSubmit = () => {
if (enabled) return submit()
toastMsg(msg, 'pick-toast')
}
if (!pulled)
return (
<div className="screen talent-pick">
@@ -33,15 +40,12 @@ export function Pick() {
</li>
))}
</ul>
{enabled ? (
<button className="primary" onClick={() => submit()}>
</button>
) : (
<button className="error">
{min == max ? min : `${min}~${max}`}
</button>
)}
<button
className={enabled ? 'primary' : 'error'}
onClick={handleSubmit}
>
{msg}
</button>
</div>
)
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { achievements, events, talents } from '@remake/data'
import { properties } from '@/display'
import { AutoInterval } from '@/config'
import { format } from '@remake/vitex'
import { toastAchvs } from '@/toast/Achv'
import './Play.css'
function LogTalent({ id }: { id: number }) {
@@ -153,13 +154,13 @@ export function Play() {
const handleNext = useCallback(() => {
if (ended) return
const achievements = next()
// TODO: Show achievements
toastAchvs(achievements)
console.debug('Achievements:', achievements)
}, [ended, next])
const handleGotoSummary = useCallback(() => {
if (!ended) return
const achievements = gotoSummary()
// TODO: Show achievements
toastAchvs(achievements)
console.debug('Achievements:', achievements)
}, [ended, gotoSummary])
useLayoutEffect(() => {
+7 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { usePicked, useEnd, useProfile, useIsClassic } from '@remake/hooks'
import { useEndJudge } from '@/hooks/judge'
import { properties, judgeDisplay } from '@/display'
import { toastAchvs, toastMsg } from '@/toast'
import Talent from '@/components/Talent'
import './Summary.css'
@@ -54,9 +55,13 @@ function TalentList({ picked, picker }: TalentListProps) {
export default function Summary() {
const isClassic = useIsClassic()
const [locked, picker, end] = useEnd()
const handlePicker = (id: number) => {
if (isClassic) picker(id)
else toastMsg('名人天赋不可锁定', 'summary-toast')
}
const handleEnd = () => {
const achievements = end()
// TODO: Show achievements
toastAchvs(achievements)
console.log('Achievements', achievements)
}
return (
@@ -68,7 +73,7 @@ export default function Summary() {
? '你可以锁定一个天赋,下辈子还能抽到'
: '名人天赋不可锁定'}
</div>
<TalentList picked={locked} picker={picker} />
<TalentList picked={locked} picker={handlePicker} />
</div>
<button className="primary" onClick={handleEnd}>
+40
View File
@@ -0,0 +1,40 @@
.achv-toast-container {
position: fixed;
width: min(calc(100dvw - 2rem), 48rem);
top: 6rem;
left: 1rem;
pointer-events: none;
user-select: none;
.toast-item {
display: flex;
position: absolute;
opacity: 1;
transition: all 0.2s ease-in-out;
&.visible {
animation: achv-fade-in 0.2s ease-out forwards;
}
&.hidden {
animation: achv-fade-out 0.2s ease-out forwards;
}
}
}
@keyframes achv-slide-in {
0% { opacity: 0; transform: translateX(-100%); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes achv-slide-out {
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(-100%); }
}
@keyframes achv-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes achv-fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
+51
View File
@@ -0,0 +1,51 @@
import { useToaster, toast } from 'react-hot-toast/headless'
import Achievement from '@/components/Achievement'
import './Achv.css'
export function ToastAchvContainer() {
const { toasts, handlers } = useToaster({ duration: 1500 }, 'achv-toast')
const { startPause, endPause, calculateOffset, updateHeight } = handlers
return (
<div
className="achv-toast-container"
onMouseEnter={startPause}
onMouseLeave={endPause}
>
{toasts.map(toast => {
const offset = calculateOffset(toast, {
reverseOrder: true,
gutter: 8,
})
console.log('toast offset', toast.id, offset)
return (
<div
className={`toast-item ${toast.visible ? 'visible' : 'hidden'}`}
key={toast.id}
ref={el => {
if (el && typeof toast.height !== 'number') {
const height = el.getBoundingClientRect().height
console.log('toast height', toast.id, height)
updateHeight(toast.id, height)
}
}}
style={{
top: `${offset}px`,
}}
>
{typeof toast.message == 'function'
? toast.message(toast)
: toast.message}
</div>
)
})}
</div>
)
}
export default ToastAchvContainer
export function toastAchvs(achievements: number[]) {
if (achievements.length === 0) return
for (const id of achievements) {
toast(<Achievement id={id} />, { id: '' + id, toasterId: 'achv-toast' })
}
}
+45
View File
@@ -0,0 +1,45 @@
.msg-toast-container {
position: fixed;
width: min(calc(100dvw - 2rem), 48rem);
top: 6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
pointer-events: none;
user-select: none;
.toast-item {
display: flex;
position: absolute;
opacity: 1;
transition: all 0.2s ease-in-out;
&.visible {
animation: msg-slide-in 0.2s ease-out forwards;
}
&.hidden {
animation: msg-fade-out 0.2s ease-out forwards;
}
}
}
@keyframes msg-slide-in {
0% { opacity: 0; transform: translateY(-100%); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes msg-slide-out {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-100%); }
}
@keyframes msg-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes msg-fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
+48
View File
@@ -0,0 +1,48 @@
import { useToaster, toast } from 'react-hot-toast/headless'
import Message from '@/components/Message'
import './Msg.css'
export function ToastMsgContainer() {
const { toasts, handlers } = useToaster({ duration: 1500 }, 'msg-toast')
const { startPause, endPause, calculateOffset, updateHeight } = handlers
return (
<div
className="msg-toast-container"
onMouseEnter={startPause}
onMouseLeave={endPause}
>
{toasts.map(toast => {
const offset = calculateOffset(toast, {
reverseOrder: true,
gutter: 8,
})
console.log('toast offset', toast.id, offset)
return (
<div
className={`toast-item ${toast.visible ? 'visible' : 'hidden'}`}
key={toast.id}
ref={el => {
if (el && typeof toast.height !== 'number') {
const height = el.getBoundingClientRect().height
console.log('toast height', toast.id, height)
updateHeight(toast.id, height)
}
}}
style={{
top: `${offset}px`,
}}
>
{typeof toast.message == 'function'
? toast.message(toast)
: toast.message}
</div>
)
})}
</div>
)
}
export default ToastMsgContainer
export function toastMsg(message: string, key: string) {
toast(<Message message={message} />, { id: key, toasterId: 'msg-toast' })
}
+13
View File
@@ -0,0 +1,13 @@
export * from './Msg'
export * from './Achv'
import ToastAchvContainer from './Achv'
import ToastMsgContainer from './Msg'
export const ToastContainer = () => (
<>
<ToastAchvContainer />
<ToastMsgContainer />
</>
)
export default ToastContainer
+26
View File
@@ -56,6 +56,9 @@ importers:
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
devDependencies:
'@types/react':
specifier: ^19.2.18
@@ -1059,6 +1062,11 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
goober@2.1.19:
resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==}
peerDependencies:
csstype: ^3.0.10
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -1503,6 +1511,13 @@ packages:
peerDependencies:
react: ^19.2.8
react-hot-toast@2.6.0:
resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -2868,6 +2883,10 @@ snapshots:
define-properties: 1.2.1
gopd: 1.2.0
goober@2.1.19(csstype@3.2.3):
dependencies:
csstype: 3.2.3
gopd@1.2.0: {}
happy-dom@20.11.2:
@@ -3280,6 +3299,13 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
react-hot-toast@2.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
csstype: 3.2.3
goober: 2.1.19(csstype@3.2.3)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-is@16.13.1: {}
react-is@17.0.2: {}