This commit is contained in:
2026-05-13 21:38:50 +08:00
commit f751439bbc
36 changed files with 8568 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src - 副本', 'src - 副本 (2)', 'src - 副本 (3)']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 515 431'%3E%3Cpath fill='%23f5f7fa' fill-rule='evenodd' d='M 404 316 L 369 271 L 406 236 L 404 152 L 443 63 L 445 51 L 441 51 L 254 246 L 167 187 L 206 101 L 205 96 L 200 97 L 133 162 L 64 115 L 53 112 L 53 118 L 92 196 L 106 209 L 107 236 L 112 245 L 153 291 L 177 291 L 204 265 L 233 282 L 277 387 L 297 379 L 303 289 L 324 274 L 340 280 L 400 317 Z M 391 175 L 394 230 L 377 246 L 351 247 L 349 239 L 353 233 L 377 233 L 382 229 L 381 189 Z M 124 222 L 129 220 L 138 225 L 141 252 L 153 261 L 163 262 L 178 250 L 185 254 L 172 270 L 152 271 L 127 243 Z'/%3E%3C/svg%3E"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="止夜工作室官网。南阳理工校内独立游戏制作团队,正在推进《希德之钥》的开发。"
/>
<title>止夜工作室 | No Stay Night Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3483
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "zhiye-studio-site",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"framer-motion": "^12.38.0",
"html2canvas": "^1.4.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"three": "^0.184.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 515 431" role="img" aria-label="Logo traced from source image in black">
<path
fill="#000000"
fill-rule="evenodd"
d="M 404 316 L 369 271 L 406 236 L 404 152 L 443 63 L 445 51 L 441 51 L 254 246 L 167 187 L 206 101 L 205 96 L 200 97 L 133 162 L 64 115 L 53 112 L 53 118 L 92 196 L 106 209 L 107 236 L 112 245 L 153 291 L 177 291 L 204 265 L 233 282 L 277 387 L 297 379 L 303 289 L 324 274 L 340 280 L 400 317 Z M 391 175 L 394 230 L 377 246 L 351 247 L 349 239 L 353 233 L 377 233 L 382 229 L 381 189 Z M 124 222 L 129 220 L 138 225 L 141 252 L 153 261 L 163 262 L 178 250 L 185 254 L 172 270 L 152 271 L 127 243 Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 515 431" role="img" aria-label="Logo traced from source image">
<path
fill="#f5f7fa"
fill-rule="evenodd"
d="M 404 316 L 369 271 L 406 236 L 404 152 L 443 63 L 445 51 L 441 51 L 254 246 L 167 187 L 206 101 L 205 96 L 200 97 L 133 162 L 64 115 L 53 112 L 53 118 L 92 196 L 106 209 L 107 236 L 112 245 L 153 291 L 177 291 L 204 265 L 233 282 L 277 387 L 297 379 L 303 289 L 324 274 L 340 280 L 400 317 Z M 391 175 L 394 230 L 377 246 L 351 247 L 349 239 L 353 233 L 377 233 L 382 229 L 381 189 Z M 124 222 L 129 220 L 138 225 L 141 252 L 153 261 L 163 262 L 178 250 L 185 254 L 172 270 L 152 271 L 127 243 Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+370
View File
@@ -0,0 +1,370 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { assetPath } from './assetPath'
import { StudioLogo } from './components/StudioLogo'
import type { IntroPhase, Locale, LocalizedText } from './types'
import { reveal, siteCopy, studioFacts, teamRoles, projectModules, workflowPoints, proofCards, contactInfo } from './data'
import { IntroOverlay } from './components/IntroOverlay'
import { SignalMiniGame } from './components/SignalMiniGame'
function t(locale: Locale, text: LocalizedText) {
return text[locale]
}
function multiline(text: string) {
return text.split('\n').map((line, index) => (
<span key={`${line}-${index}`}>
{line}
{index < text.split('\n').length - 1 ? <br /> : null}
</span>
))
}
function Eyebrow({ children }: { children: string }) {
return (
<span className="eyebrow">
<span className="eyebrow-line" aria-hidden="true" />
<span className="eyebrow-copy">{children}</span>
</span>
)
}
function HeroTitle({ title }: { title: string }) {
const lines = title.split(/\s+/).length > 2 ? title.split(/\s+/) : title.split('\n')
const rendered = lines.length > 1 ? lines : title.split(/(?<=夜)(?=工)|(?<=No Stay)(?= Night Studio)/)
return (
<h1 className="hero-title-glitch" aria-label="止夜工作室">
<span className="hero-title-main">
{rendered.map((line, index) => (
<span key={`${line}-${index}`}>
{line}
{index < rendered.length - 1 ? <br /> : null}
</span>
))}
</span>
</h1>
)
}
function ProofCarousel({ locale }: { locale: Locale }) {
const [activeIndex, setActiveIndex] = useState(0)
const [direction, setDirection] = useState(1)
const activeCard = proofCards[activeIndex]
const showCard = (nextIndex: number) => {
const total = proofCards.length
const normalized = (nextIndex + total) % total
setDirection(normalized > activeIndex || (activeIndex === total - 1 && normalized === 0) ? 1 : -1)
setActiveIndex(normalized)
}
useEffect(() => {
const autoTimer = window.setInterval(() => {
setDirection(1)
setActiveIndex((current) => (current + 1) % proofCards.length)
}, 4600)
return () => window.clearInterval(autoTimer)
}, [])
return (
<div className="proof-carousel">
<div className="proof-carousel-stage">
<div className="proof-carousel-media">
<AnimatePresence mode="wait" initial={false}>
<motion.img
key={activeCard.image}
src={activeCard.image}
alt={t(locale, activeCard.alt)}
custom={direction}
initial={{ opacity: 0, scale: 1.06, x: direction > 0 ? 48 : -48 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.98, x: direction > 0 ? -36 : 36 }}
transition={{ duration: 0.62, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
<div className="proof-carousel-media-glow" aria-hidden="true" />
</div>
<div className="proof-carousel-copy">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={t(locale, activeCard.title)}
className="proof-carousel-copy-inner"
custom={direction}
initial={{ opacity: 0, y: 26, x: direction > 0 ? 18 : -18 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: -18, x: direction > 0 ? -12 : 12 }}
transition={{ duration: 0.44, ease: [0.22, 1, 0.36, 1] }}
>
<span>{activeCard.tag}</span>
<strong>{t(locale, activeCard.title)}</strong>
<p>{t(locale, activeCard.note)}</p>
</motion.div>
</AnimatePresence>
<div className="proof-carousel-controls">
<button type="button" className="proof-nav-button" onClick={() => showCard(activeIndex - 1)} aria-label={t(locale, siteCopy.proof.previous)}>
{t(locale, siteCopy.proof.previous)}
</button>
<div className="proof-carousel-progress" aria-hidden="true">
{proofCards.map((card, index) => (
<span key={t(locale, card.title)} className={index === activeIndex ? 'is-active' : ''} />
))}
</div>
<button type="button" className="proof-nav-button" onClick={() => showCard(activeIndex + 1)} aria-label={t(locale, siteCopy.proof.next)}>
{t(locale, siteCopy.proof.next)}
</button>
</div>
</div>
</div>
<div className="proof-carousel-rail" role="tablist" aria-label={t(locale, siteCopy.proof.railLabel)}>
{proofCards.map((card, index) => (
<button
key={t(locale, card.title)}
type="button"
role="tab"
aria-selected={index === activeIndex}
className={`proof-rail-card${index === activeIndex ? ' is-active' : ''}`}
onClick={() => showCard(index)}
>
<div className="proof-rail-thumb">
<img src={card.image} alt="" />
</div>
<div className="proof-rail-copy">
<span>{card.tag}</span>
<strong>{t(locale, card.title)}</strong>
</div>
</button>
))}
</div>
</div>
)
}
const INTRO_CLOSE_AT_MS = 2200
function App() {
const [introPhase, setIntroPhase] = useState<IntroPhase>('intro')
const [locale, setLocale] = useState<Locale>('zh')
const workflowBoardAsset = assetPath('assets/workflow-board.jpg')
const requestCloseIntro = () => {
setIntroPhase((current) => (current === 'intro' ? 'closing' : current))
}
useEffect(() => {
const closeTimer = window.setTimeout(() => requestCloseIntro(), INTRO_CLOSE_AT_MS)
return () => { window.clearTimeout(closeTimer) }
}, [])
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = introPhase === 'hidden' ? '' : 'hidden'
return () => { document.body.style.overflow = prev }
}, [introPhase])
const handleIntroComplete = () => {
setIntroPhase('hidden')
}
useEffect(() => {
if (introPhase === 'hidden') return
const onWheel = (e: WheelEvent) => {
if (e.deltaY > 0) {
requestCloseIntro()
}
}
window.addEventListener('wheel', onWheel, { passive: true, once: true })
return () => window.removeEventListener('wheel', onWheel)
}, [introPhase])
return (
<div className="site-root">
<IntroOverlay phase={introPhase} onSkip={requestCloseIntro} onComplete={handleIntroComplete} />
<div className="page-shell page-shell-visible">
<header className="topbar">
<a className="brand" href="#hero">
<span className="brand-mark">
<StudioLogo title="止夜工作室 logo" />
</span>
<span className="brand-copy">
<strong></strong>
<span>No Stay Night Studio</span>
</span>
</a>
<div className="topbar-actions">
<nav className="nav" aria-label={locale === 'zh' ? '主导航' : 'Main navigation'}>
<a href="#about">{t(locale, siteCopy.nav.about)}</a>
<a href="#project">{t(locale, siteCopy.nav.project)}</a>
<a href="#workflow">{t(locale, siteCopy.nav.workflow)}</a>
<a href="#proof">{t(locale, siteCopy.nav.proof)}</a>
<a href="#contact">{t(locale, siteCopy.nav.contact)}</a>
</nav>
<div className="locale-switch" aria-label={locale === 'zh' ? '语言切换' : 'Language switch'}>
<button type="button" className={locale === 'zh' ? 'is-active' : ''} onClick={() => setLocale('zh')}></button>
<button type="button" className={locale === 'en' ? 'is-active' : ''} onClick={() => setLocale('en')}>EN</button>
</div>
</div>
</header>
<main>
<motion.section className="section hero content-hero" id="hero" {...reveal}>
<div className="content-hero-layout">
<div className="hero-side-rail" aria-hidden="true">
<span className="hero-rail-text">NO STAY NIGHT STUDIO</span>
<span className="hero-rail-line" />
<span className="hero-rail-year">EST. 2024</span>
</div>
<div className="content-hero-copy">
<Eyebrow>{t(locale, siteCopy.hero.eyebrow)}</Eyebrow>
<HeroTitle title={t(locale, siteCopy.hero.title)} />
<p className="content-hero-tagline">{t(locale, siteCopy.hero.tagline)}</p>
<p className="content-hero-description">
{t(locale, siteCopy.hero.description)}
</p>
<div className="hero-meta-strip">
{siteCopy.hero.meta.map((item) => (
<span key={t(locale, item)}>{t(locale, item)}</span>
))}
</div>
</div>
<div className="content-hero-visual" aria-hidden="true">
<StudioLogo className="hero-logo-display" title="" />
</div>
</div>
</motion.section>
<motion.section className="section section-about" id="about" {...reveal}>
<div className="about-header">
<span className="section-index">01</span>
<Eyebrow>{t(locale, siteCopy.about.eyebrow)}</Eyebrow>
<h2>{multiline(t(locale, siteCopy.about.heading))}</h2>
</div>
<div className="about-statement-full">
<p className="statement-lead">{t(locale, siteCopy.about.statementLead)}</p>
<div className="about-body-cols">
{siteCopy.about.body.map((paragraph) => (
<p key={t(locale, paragraph)}>{t(locale, paragraph)}</p>
))}
</div>
</div>
<div className="about-data-strip">
{studioFacts.map((fact) => (
<div key={t(locale, fact.label)} className="data-cell">
<span className="data-label">{t(locale, fact.label)}</span>
<strong className="data-value">{t(locale, fact.value)}</strong>
<p className="data-note">{t(locale, fact.note)}</p>
</div>
))}
<div className="data-divider" />
{teamRoles.map((role) => (
<div key={t(locale, role.role)} className="data-cell">
<span className="data-label">{t(locale, role.role)}</span>
<strong className="data-value">{t(locale, role.count)}</strong>
<p className="data-note">{t(locale, role.note)}</p>
</div>
))}
</div>
</motion.section>
<motion.section className="section section-project" id="project" {...reveal}>
<div className="project-masthead">
<span className="section-index">02</span>
<div className="project-masthead-copy">
<Eyebrow>{t(locale, siteCopy.project.eyebrow)}</Eyebrow>
<h2>{t(locale, siteCopy.project.title)}</h2>
<p className="project-sub">{t(locale, siteCopy.project.subtitle)}</p>
</div>
<div className="project-status-strip">
{siteCopy.project.status.map((item) => (
<span key={t(locale, item)}>{t(locale, item)}</span>
))}
</div>
</div>
<div className="project-modules-row">
{projectModules.map((item) => (
<article key={item.index} className="project-module-item">
<span className="module-index">{item.index}</span>
<h3>{t(locale, item.title)}</h3>
<p>{t(locale, item.note)}</p>
</article>
))}
</div>
<SignalMiniGame locale={locale} />
</motion.section>
<motion.section className="section section-workflow" id="workflow" {...reveal}>
<div className="workflow-header">
<span className="section-index">03</span>
<Eyebrow>{t(locale, siteCopy.workflow.eyebrow)}</Eyebrow>
</div>
<div className="workflow-asymmetric">
<div className="workflow-text-col">
<h2>{t(locale, siteCopy.workflow.heading)}</h2>
<div className="workflow-points">
{workflowPoints.map((item) => (
<div key={`${item.id}-${locale}`} className="workflow-point">
<span className="wp-id">{item.id}</span>
<div>
<h3>{t(locale, item.title)}</h3>
<p>{t(locale, item.note)}</p>
</div>
</div>
))}
</div>
</div>
<div className="workflow-image-col">
<article className="framed-image framed-image-large">
<div className="image-caption">
<span>{t(locale, siteCopy.workflow.boardTag)}</span>
<strong>{t(locale, siteCopy.workflow.boardTitle)}</strong>
</div>
<img src={workflowBoardAsset} alt={t(locale, siteCopy.workflow.boardAlt)} />
</article>
</div>
</div>
</motion.section>
<motion.section className="section section-proof" id="proof" {...reveal}>
<div className="proof-header">
<span className="section-index">04</span>
<Eyebrow>{t(locale, siteCopy.proof.eyebrow)}</Eyebrow>
<h2>{t(locale, siteCopy.proof.heading)}</h2>
</div>
<ProofCarousel locale={locale} />
</motion.section>
<motion.section className="section contact-section section-contact" id="contact" {...reveal}>
<div className="contact-raw">
<span className="section-index">05</span>
<Eyebrow>{t(locale, siteCopy.contact.eyebrow)}</Eyebrow>
<p className="contact-invite">{t(locale, siteCopy.contact.invite)}</p>
<div className="contact-raw-list">
{contactInfo.map((item) =>
item.href ? (
<a key={t(locale, item.label)} className="contact-raw-item" href={item.href}>
<span>{t(locale, item.label)}</span>
<strong>{item.value}</strong>
</a>
) : (
<div key={t(locale, item.label)} className="contact-raw-item">
<span>{t(locale, item.label)}</span>
<strong>{item.value}</strong>
</div>
)
)}
</div>
</div>
</motion.section>
</main>
<footer className="site-footer">{t(locale, siteCopy.footer)}</footer>
</div>
</div>
)
}
export default App
+3
View File
@@ -0,0 +1,3 @@
export function assetPath(path: string) {
return `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+973
View File
@@ -0,0 +1,973 @@
import html2canvas from 'html2canvas'
import { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import type { CSSProperties, RefObject } from 'react'
import { StudioLogo } from './StudioLogo'
import type { IntroPhase } from '../types'
type PercentPoint = {
x: number
y: number
}
type CrackSegment = {
d: string
cls: string
delay: string
}
type ShardSpec = {
delayMs: number
points: PercentPoint[]
centroid: PercentPoint
rotationDeg: number
spinXDeg: number
spinYDeg: number
spinZDeg: number
tiltXDeg: number
tiltYDeg: number
translateXPct: number
translateYPct: number
}
type ClosingStage = 'crack' | 'shatter'
const SHATTER_SNAPSHOT_SCALE = 1.25
const SHATTER_RENDERER_PIXEL_RATIO = 1.25
const SHARD_DURATION_MS = 1720
const CRACK_GROWTH_DURATION_MS = 600
const SNAPSHOT_CAPTURE_LEAD_MS = 10
const pt = (x: number, y: number): PercentPoint => ({ x, y })
function IntroBlocks() {
const blocks = [
{ w: '18vw', h: '2px', x: '-20vw', y: '28%', delay: '0s', dur: '3.2s', dir: 'h' },
{ w: '2px', h: '22vh', x: '62%', y: '-25vh', delay: '0.2s', dur: '2.8s', dir: 'v' },
{ w: '12vw', h: '2px', x: '105vw', y: '54%', delay: '0.4s', dur: '3.5s', dir: 'h-r' },
{ w: '2px', h: '16vh', x: '38%', y: '110vh', delay: '0.6s', dur: '3.0s', dir: 'v-b' },
{ w: '8vw', h: '1px', x: '-10vw', y: '72%', delay: '0.8s', dur: '2.6s', dir: 'h' },
{ w: '1px', h: '30vh', x: '78%', y: '-35vh', delay: '1.0s', dur: '3.8s', dir: 'v' },
{ w: '22vw', h: '1px', x: '105vw', y: '18%', delay: '0.3s', dur: '2.9s', dir: 'h-r' },
{ w: '1px', h: '12vh', x: '22%', y: '110vh', delay: '0.7s', dur: '3.3s', dir: 'v-b' },
{ w: '6vw', h: '2px', x: '-8vw', y: '88%', delay: '1.2s', dur: '2.4s', dir: 'h' },
{ w: '2px', h: '8vh', x: '88%', y: '-12vh', delay: '0.5s', dur: '3.6s', dir: 'v' },
]
return (
<div className="intro-blocks" aria-hidden="true">
{blocks.map((b, i) => (
<span
key={i}
className={`intro-block intro-block-${b.dir}`}
style={({
width: b.w,
height: b.h,
left: b.x,
top: b.y,
animationDelay: b.delay,
animationDuration: b.dur,
}) as CSSProperties}
/>
))}
</div>
)
}
function IntroSvgLines() {
return (
<svg
className="intro-svg-lines"
viewBox="0 0 1000 600"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
{[0, 22, 45, 67, 90, 112, 135, 157, 180, 202, 225, 247, 270, 292, 315, 337].map((angle, i) => {
const rad = (angle * Math.PI) / 180
const x2 = 500 + Math.cos(rad) * 600
const y2 = 300 + Math.sin(rad) * 600
return (
<line
key={i}
x1="500"
y1="300"
x2={x2}
y2={y2}
className="svg-radial-ray"
style={{ '--ray-i': i, '--ray-delay': `${i * 0.08}s` } as CSSProperties}
/>
)
})}
<ellipse cx="500" cy="300" rx="280" ry="80" className="svg-orbit svg-orbit-1" />
<ellipse cx="500" cy="300" rx="200" ry="55" className="svg-orbit svg-orbit-2" />
<ellipse cx="500" cy="300" rx="380" ry="110" className="svg-orbit svg-orbit-3" />
<defs>
<linearGradient id="scanBeam" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="rgba(77,124,255,0)" />
<stop offset="50%" stopColor="rgba(77,124,255,0.18)" />
<stop offset="100%" stopColor="rgba(77,124,255,0)" />
</linearGradient>
<path id="orbit-path-1" d="M 780 300 A 280 80 0 1 0 779.9 300" />
<path id="orbit-path-2" d="M 700 300 A 200 55 0 1 1 699.9 300" />
</defs>
<>
<rect x="0" y="0" width="1000" height="3" fill="url(#scanBeam)" className="svg-h-scan" />
<rect x="0" y="0" width="1000" height="1" fill="rgba(232,234,240,0.25)" className="svg-h-scan svg-h-scan-sharp" />
</>
<path d="M 0 600 Q 500 0 1000 600" className="svg-flow-path svg-flow-1" />
<path d="M 0 0 Q 500 600 1000 0" className="svg-flow-path svg-flow-2" />
<line x1="500" y1="200" x2="500" y2="400" className="svg-cross-v" />
<line x1="400" y1="300" x2="600" y2="300" className="svg-cross-h" />
<>
<circle r="3" className="svg-orbit-dot svg-orbit-dot-1">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#orbit-path-1" />
</animateMotion>
</circle>
<circle r="2" className="svg-orbit-dot svg-orbit-dot-2">
<animateMotion dur="4.5s" repeatCount="indefinite" begin="-1.5s">
<mpath href="#orbit-path-2" />
</animateMotion>
</circle>
</>
</svg>
)
}
const crackCenter = pt(50, 52)
const SLASH_DIRECTION = (() => {
const raw = { x: 0.82, y: 0.57 }
const length = Math.hypot(raw.x, raw.y)
return { x: raw.x / length, y: raw.y / length }
})()
const SLASH_NORMAL = {
x: -SLASH_DIRECTION.y,
y: SLASH_DIRECTION.x,
}
function averagePoint(points: PercentPoint[]) {
const total = points.reduce(
(acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }),
{ x: 0, y: 0 },
)
return {
x: total.x / points.length,
y: total.y / points.length,
}
}
const crackViewport = {
width: 1000,
height: 600,
} as const
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function fract(value: number) {
return value - Math.floor(value)
}
function pseudoNoise(seed: number) {
return fract(Math.sin(seed * 127.1 + seed * 0.13) * 43758.5453123)
}
function normalizePercentPoint(point: PercentPoint) {
const x = clamp(point.x, 0, 100)
const y = clamp(point.y, 0, 100)
return {
x: sameValue(x, 0) ? 0 : sameValue(x, 100) ? 100 : x,
y: sameValue(y, 0) ? 0 : sameValue(y, 100) ? 100 : y,
}
}
function sameValue(a: number, b: number) {
return Math.abs(a - b) < 0.001
}
function isBorderEdge(a: PercentPoint, b: PercentPoint) {
return (
(sameValue(a.x, 0) && sameValue(b.x, 0)) ||
(sameValue(a.x, 100) && sameValue(b.x, 100)) ||
(sameValue(a.y, 0) && sameValue(b.y, 0)) ||
(sameValue(a.y, 100) && sameValue(b.y, 100))
)
}
function distance(a: PercentPoint, b: PercentPoint) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function dot(a: PercentPoint, b: PercentPoint) {
return a.x * b.x + a.y * b.y
}
function polygonArea(points: PercentPoint[]) {
let area = 0
for (let i = 0; i < points.length; i += 1) {
const current = points[i]
const next = points[(i + 1) % points.length]
area += current.x * next.y - next.x * current.y
}
return Math.abs(area) * 0.5
}
function polygonCentroid(points: PercentPoint[]) {
let signedArea = 0
let centroidX = 0
let centroidY = 0
for (let i = 0; i < points.length; i += 1) {
const current = points[i]
const next = points[(i + 1) % points.length]
const cross = current.x * next.y - next.x * current.y
signedArea += cross
centroidX += (current.x + next.x) * cross
centroidY += (current.y + next.y) * cross
}
if (Math.abs(signedArea) < 0.001) {
return averagePoint(points)
}
return normalizePercentPoint({
x: centroidX / (3 * signedArea),
y: centroidY / (3 * signedArea),
})
}
function collapseClosePoints(points: PercentPoint[], epsilon = 0.08) {
const cleaned: PercentPoint[] = []
points.forEach((point) => {
const normalized = normalizePercentPoint(point)
if (cleaned.length === 0 || distance(cleaned[cleaned.length - 1], normalized) > epsilon) {
cleaned.push(normalized)
}
})
if (cleaned.length > 1 && distance(cleaned[0], cleaned[cleaned.length - 1]) <= epsilon) {
cleaned.pop()
}
return cleaned
}
function simplifyPolygon(points: PercentPoint[]) {
let simplified = collapseClosePoints(points)
let changed = true
while (changed && simplified.length > 3) {
changed = false
const nextPass: PercentPoint[] = []
for (let i = 0; i < simplified.length; i += 1) {
const prev = simplified[(i - 1 + simplified.length) % simplified.length]
const current = simplified[i]
const next = simplified[(i + 1) % simplified.length]
const cross = Math.abs(
(current.x - prev.x) * (next.y - current.y) -
(current.y - prev.y) * (next.x - current.x),
)
if (cross < 0.06 && distance(prev, next) > 0.24) {
changed = true
continue
}
nextPass.push(current)
}
simplified = collapseClosePoints(nextPass)
}
return simplified
}
function fromSlashSpace(along: number, across: number) {
return normalizePercentPoint({
x: crackCenter.x + SLASH_DIRECTION.x * along + SLASH_NORMAL.x * across,
y: crackCenter.y + SLASH_DIRECTION.y * along + SLASH_NORMAL.y * across,
})
}
function buildGlassSeedPoints() {
const seeds: PercentPoint[] = [crackCenter]
const lanes = [-28, -16, -6, 6, 16, 28]
const steps = [-58, -38, -18, 2, 22, 42, 62]
lanes.forEach((across, laneIndex) => {
steps.forEach((along, stepIndex) => {
if (laneIndex === 2 && stepIndex === 3) return
const alongJitter = (pseudoNoise(100 + laneIndex * 20 + stepIndex) - 0.5) * 7.2
const acrossJitter = (pseudoNoise(300 + laneIndex * 20 + stepIndex) - 0.5) * 5.6
const taper = 1 - Math.min(0.35, Math.abs(across) / 120)
seeds.push(fromSlashSpace(
along + alongJitter * taper,
across + acrossJitter * (0.72 + Math.abs(across) / 48),
))
})
})
const flankLanes = [-38, 38]
const flankSteps = [-44, -14, 16, 46]
flankLanes.forEach((across, laneIndex) => {
flankSteps.forEach((along, stepIndex) => {
seeds.push(fromSlashSpace(
along + (pseudoNoise(500 + laneIndex * 20 + stepIndex) - 0.5) * 6.2,
across + (pseudoNoise(600 + laneIndex * 20 + stepIndex) - 0.5) * 6.6,
))
})
})
const pushEdgeSeed = (x: number, y: number, seed: number, xJitter: number, yJitter: number) => {
seeds.push(normalizePercentPoint({
x: x + (pseudoNoise(seed) - 0.5) * xJitter,
y: y + (pseudoNoise(seed + 17) - 0.5) * yJitter,
}))
}
;[12, 34, 58, 84].forEach((x, index) => pushEdgeSeed(x, 7.4, 400 + index, 3.4, 1.2))
;[16, 40, 64, 88].forEach((x, index) => pushEdgeSeed(x, 92.6, 500 + index, 3.4, 1.2))
;[18, 44, 72].forEach((y, index) => pushEdgeSeed(7.3, y, 600 + index, 1.2, 4))
;[22, 50, 78].forEach((y, index) => pushEdgeSeed(92.7, y, 700 + index, 1.2, 4))
return seeds
}
function clipPolygonWithBisector(polygon: PercentPoint[], seed: PercentPoint, other: PercentPoint) {
const clipped: PercentPoint[] = []
const direction = {
x: other.x - seed.x,
y: other.y - seed.y,
}
const midpoint = {
x: (seed.x + other.x) * 0.5,
y: (seed.y + other.y) * 0.5,
}
const signedDistance = (point: PercentPoint) =>
(point.x - midpoint.x) * direction.x + (point.y - midpoint.y) * direction.y
for (let i = 0; i < polygon.length; i += 1) {
const current = polygon[i]
const next = polygon[(i + 1) % polygon.length]
const currentDistance = signedDistance(current)
const nextDistance = signedDistance(next)
const currentInside = currentDistance <= 0.0001
const nextInside = nextDistance <= 0.0001
if (currentInside && nextInside) {
clipped.push(next)
continue
}
if (currentInside !== nextInside) {
const t = currentDistance / (currentDistance - nextDistance)
clipped.push(normalizePercentPoint({
x: current.x + (next.x - current.x) * t,
y: current.y + (next.y - current.y) * t,
}))
}
if (!currentInside && nextInside) {
clipped.push(next)
}
}
return simplifyPolygon(clipped)
}
function buildGlassVoronoiCells() {
const seeds = buildGlassSeedPoints()
const bounds = [pt(0, 0), pt(100, 0), pt(100, 100), pt(0, 100)]
return seeds.map((seed, index) => {
let polygon = [...bounds]
seeds.forEach((other, otherIndex) => {
if (otherIndex === index || polygon.length < 3) return
polygon = clipPolygonWithBisector(polygon, seed, other)
})
return simplifyPolygon(polygon)
})
}
function edgeKey(a: PercentPoint, b: PercentPoint) {
const first = normalizePercentPoint(a)
const second = normalizePercentPoint(b)
const ordered =
first.x < second.x || (sameValue(first.x, second.x) && first.y <= second.y)
? [first, second]
: [second, first]
return `${ordered[0].x.toFixed(2)},${ordered[0].y.toFixed(2)}|${ordered[1].x.toFixed(2)},${ordered[1].y.toFixed(2)}`
}
function toSvgPoint(point: PercentPoint) {
return {
x: (point.x / 100) * crackViewport.width,
y: (point.y / 100) * crackViewport.height,
}
}
function buildJaggedCrackPath(
start: PercentPoint,
end: PercentPoint,
jitterSeed: number,
roughness = 1,
) {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.hypot(dx, dy)
const nx = length === 0 ? 0 : -dy / length
const ny = length === 0 ? 0 : dx / length
const points = [start]
const kinkSteps =
roughness < 0.34
? [0.5]
: length < 16
? [0.52]
: [0.32, 0.68]
const amplitude = (Math.min(1.8, 0.45 + length * 0.02)) * roughness
kinkSteps.forEach((t, index) => {
const base = {
x: start.x + dx * t,
y: start.y + dy * t,
}
const wave = (jitterSeed + index) % 2 === 0 ? 1 : -1
const taper = index === 0 ? 1 : 0.72
points.push({
x: base.x + nx * amplitude * wave * taper,
y: base.y + ny * amplitude * wave * taper,
})
})
points.push(end)
return points
.map((point, index) => {
const svgPoint = toSvgPoint(point)
return `${index === 0 ? 'M' : 'L'}${svgPoint.x.toFixed(1)} ${svgPoint.y.toFixed(1)}`
})
.join(' ')
}
function createCrackSegment(start: PercentPoint, end: PercentPoint, order: number): CrackSegment | null {
if (distance(start, end) < 4) return null
const mid = {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
}
const segment = { x: end.x - start.x, y: end.y - start.y }
const segmentLength = Math.hypot(segment.x, segment.y)
const segmentUnit =
segmentLength === 0
? { x: 0, y: 0 }
: { x: segment.x / segmentLength, y: segment.y / segmentLength }
const centerOffset = { x: mid.x - crackCenter.x, y: mid.y - crackCenter.y }
const slashDistance = Math.abs(dot(centerOffset, SLASH_NORMAL))
const slashTravel = Math.abs(dot(centerOffset, SLASH_DIRECTION))
const slashAlignment = Math.abs(dot(segmentUnit, SLASH_DIRECTION))
const centerDistance = distance(mid, crackCenter)
const isSlashCore = slashAlignment > 0.84 && slashDistance < 6.5
const isSlashSupport = slashAlignment > 0.7 && slashDistance < 15
const band = isSlashCore
? 'intro-crack-path-slash'
: isSlashSupport
? 'intro-crack-path-main'
: centerDistance < 26
? 'intro-crack-path-branch'
: 'intro-crack-path-fray'
const secondary = !isSlashCore && (centerDistance > 20 || slashAlignment < 0.52) ? ' intro-crack-path-secondary' : ''
const roughness = isSlashCore ? 0.24 : isSlashSupport ? 0.42 : centerDistance < 26 ? 0.62 : 0.82
const delay = 0.12 + Math.min((slashDistance * 0.7 + slashTravel * 0.18) / 60, 1) * 0.38 + order * 0.003
return {
d: buildJaggedCrackPath(start, end, order, roughness),
cls: `${band}${secondary}`,
delay: `${delay.toFixed(2)}s`,
}
}
const shardPolygons = buildGlassVoronoiCells()
const crackSegments = (() => {
const internalEdges = new Map<string, { a: PercentPoint; b: PercentPoint; count: number }>()
shardPolygons.forEach((polygon) => {
polygon.forEach((point, index) => {
const next = polygon[(index + 1) % polygon.length]
if (isBorderEdge(point, next)) return
const key = edgeKey(point, next)
const existing = internalEdges.get(key)
if (existing) {
existing.count += 1
} else {
internalEdges.set(key, { a: normalizePercentPoint(point), b: normalizePercentPoint(next), count: 1 })
}
})
})
return Array.from(internalEdges.values())
.filter((edge) => edge.count > 1 && distance(edge.a, edge.b) > 2.8)
.map((edge, index) => createCrackSegment(edge.a, edge.b, index))
.filter((segment): segment is CrackSegment => segment !== null)
})()
const shardSpecs: ShardSpec[] = shardPolygons.map((points, index) => {
const centroid = polygonCentroid(points)
const area = polygonArea(points)
const dx = (centroid.x - crackCenter.x) / 50
const dy = (centroid.y - crackCenter.y) / 50
const radial = Math.hypot(dx, dy)
const spreadNoise = pseudoNoise(900 + index) - 0.5
const delay = Math.max(0, radial * 0.068 - 0.015) + (area < 88 ? 0.026 : 0)
const slashOffset = dot({ x: centroid.x - crackCenter.x, y: centroid.y - crackCenter.y }, SLASH_NORMAL)
const slashAlong = dot({ x: centroid.x - crackCenter.x, y: centroid.y - crackCenter.y }, SLASH_DIRECTION)
const slashPull = slashOffset / 18
const directionalBurst = slashAlong > 0 ? 1.2 : 0.72
const outwardScatter = (dx === 0 ? Math.sign(spreadNoise || 1) : Math.sign(dx)) * (6 + radial * 10)
const lateral = slashPull * 12.5 + SLASH_DIRECTION.x * directionalBurst * 16 + outwardScatter + spreadNoise * 4.8
const vertical = 102 + radial * 28 + directionalBurst * 10 + Math.max(0, 120 - area) * 0.14
const slashDrift = SLASH_DIRECTION.y * directionalBurst * 28
const diagonalLift = Math.abs(slashPull) * 10
return {
delayMs: delay * 1000,
centroid,
points,
rotationDeg: slashPull * 1.6 + spreadNoise * 1.4,
spinXDeg: spreadNoise * 46 + dx * 18,
spinYDeg: slashPull * 44 + spreadNoise * 30,
spinZDeg: slashPull * 22 + dx * 14,
tiltXDeg: -dy * 9 + spreadNoise * 4,
tiltYDeg: slashPull * 13 + dx * 7 + spreadNoise * 7,
translateXPct: lateral,
translateYPct: vertical + slashDrift - diagonalLift,
}
})
function clamp01(value: number) {
return Math.max(0, Math.min(1, value))
}
function lerp(from: number, to: number, t: number) {
return from + (to - from) * t
}
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3)
}
function easeInCubic(t: number) {
return t * t * t
}
function toScenePoint(point: PercentPoint, width: number, height: number) {
return new THREE.Vector2(
(point.x / 100) * width - width / 2,
height / 2 - (point.y / 100) * height,
)
}
function createShardMeshData(spec: ShardSpec, width: number, height: number) {
const center = toScenePoint(spec.centroid, width, height)
const shapePoints = spec.points.map((point) => {
const worldPoint = toScenePoint(point, width, height)
return new THREE.Vector2(worldPoint.x - center.x, worldPoint.y - center.y)
})
const shape = new THREE.Shape(shapePoints)
const geometry = new THREE.ShapeGeometry(shape)
const position = geometry.getAttribute('position')
const uv = new Float32Array(position.count * 2)
for (let i = 0; i < position.count; i += 1) {
const x = position.getX(i) + center.x
const y = position.getY(i) + center.y
uv[i * 2] = (x + width / 2) / width
uv[i * 2 + 1] = (y + height / 2) / height
}
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
const outlinePoints = spec.points.map((point) => {
const worldPoint = toScenePoint(point, width, height)
return new THREE.Vector3(worldPoint.x - center.x, worldPoint.y - center.y, 0.5)
})
return { center, geometry, outlinePoints }
}
function IntroCrackGrowth() {
return (
<div className="intro-crack-growth-wrap" aria-hidden="true">
<svg className="intro-crack-growth" viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice">
{crackSegments.map((segment, i) => (
<path
key={i}
d={segment.d}
pathLength={100}
className={`intro-crack-path ${segment.cls}`}
style={{ '--crack-delay': segment.delay } as CSSProperties}
/>
))}
</svg>
</div>
)
}
function WordmarkLine({
delay,
word,
}: {
delay: number
word: string
}) {
const trailLayers = [1, 2, 3, 4, 5, 6]
return (
<span
className="intro-wordmark-line"
style={{ '--wordmark-delay': `${delay}s` } as CSSProperties}
>
<span className="intro-wordmark-trail" aria-hidden="true">
{trailLayers.map((layer) => (
<span key={layer} className={`intro-wordmark-trail-layer intro-wordmark-trail-layer-${layer}`}>
{word}
</span>
))}
</span>
<span className="intro-wordmark-core">{word}</span>
</span>
)
}
function IntroSource({
sourceRef,
closing,
}: {
sourceRef?: RefObject<HTMLDivElement | null>
closing: boolean
}) {
const words = ['No', 'Stay', 'Night', 'Studio'] as const
return (
<div ref={sourceRef} className={`intro-snapshot-source${closing ? ' is-closing-source' : ''}`}>
<div className="intro-overlay-backdrop" />
<div className={`intro-main-content${closing ? ' is-closing-live' : ''}`}>
<div className="intro-hero-wordmark" aria-hidden="true">
{words.map((word, i) => (
<WordmarkLine
key={word}
word={word}
delay={0.06 + i * 0.04}
/>
))}
</div>
<motion.div
className="intro-hero-logo"
initial={{ x: '110vw' }}
animate={{ x: 0, y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.88, delay: 0.18, ease: [0.22, 1, 0.36, 1] }}
aria-hidden="true"
>
<StudioLogo />
</motion.div>
<div className="intro-line-cloud" />
<IntroSvgLines />
<div className="intro-grid-layer" />
<IntroBlocks />
</div>
</div>
)
}
function ClosingOverlay({
sourceRef,
onComplete,
onShatterStart,
}: {
sourceRef: RefObject<HTMLDivElement | null>
onComplete: () => void
onShatterStart: (hidden: boolean) => void
}) {
const shatterHostRef = useRef<HTMLDivElement | null>(null)
const animationFrameRef = useRef<number | null>(null)
const [snapshotCanvas, setSnapshotCanvas] = useState<HTMLCanvasElement | null>(null)
const [crackFinished, setCrackFinished] = useState(false)
const [freezeLiveFrame, setFreezeLiveFrame] = useState(false)
const stage: ClosingStage = crackFinished && snapshotCanvas ? 'shatter' : 'crack'
useEffect(() => {
onShatterStart(stage === 'shatter')
}, [onShatterStart, stage])
useEffect(() => {
let cancelled = false
let crackTimer = 0
let holdTimer = 0
const pauseAnimations = () => {
const source = sourceRef.current
if (!source) return
source.querySelectorAll<HTMLElement>('*').forEach((node) => {
const animations = node.getAnimations?.() ?? []
animations.forEach((animation) => {
try {
animation.pause()
} catch {
// Ignore non-pausable animations.
}
})
})
}
const captureSnapshot = async () => {
await new Promise<void>((resolve) => {
holdTimer = window.setTimeout(
resolve,
Math.max(0, CRACK_GROWTH_DURATION_MS - SNAPSHOT_CAPTURE_LEAD_MS),
)
})
if (cancelled) return
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
if (cancelled) return
setFreezeLiveFrame(true)
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
if (cancelled) return
pauseAnimations()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
if (cancelled) return
const source = sourceRef.current
if (cancelled || !source) return
const bounds = source.getBoundingClientRect()
const canvas = await html2canvas(source, {
backgroundColor: null,
scale: Math.min(window.devicePixelRatio || 1, SHATTER_SNAPSHOT_SCALE),
logging: false,
useCORS: true,
scrollX: 0,
scrollY: 0,
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
windowWidth: Math.max(1, Math.round(bounds.width)),
windowHeight: Math.max(1, Math.round(bounds.height)),
})
if (!cancelled) {
setSnapshotCanvas(canvas)
}
}
crackTimer = window.setTimeout(() => {
if (!cancelled) setCrackFinished(true)
}, CRACK_GROWTH_DURATION_MS)
void captureSnapshot()
return () => {
cancelled = true
setFreezeLiveFrame(false)
window.clearTimeout(holdTimer)
window.clearTimeout(crackTimer)
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}
}, [sourceRef])
useEffect(() => {
if (stage !== 'shatter' || !snapshotCanvas || !shatterHostRef.current) return
const host = shatterHostRef.current
const dpr = Math.min(window.devicePixelRatio || 1, SHATTER_RENDERER_PIXEL_RATIO)
const width = host.clientWidth || window.innerWidth
const height = host.clientHeight || window.innerHeight
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false, powerPreference: 'high-performance' })
renderer.setPixelRatio(dpr)
renderer.setSize(width, height, false)
renderer.setClearColor(0x000000, 0)
renderer.sortObjects = false
host.innerHTML = ''
host.appendChild(renderer.domElement)
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.1, 2000)
camera.position.z = 10
const texture = new THREE.CanvasTexture(snapshotCanvas)
texture.flipY = true
texture.colorSpace = THREE.SRGBColorSpace
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
texture.generateMipmaps = false
const baseMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.FrontSide,
})
const outlineMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.08,
depthTest: false,
depthWrite: false,
})
const maxShardDelayMs = Math.max(...shardSpecs.map((shard) => shard.delayMs))
const totalDurationMs = maxShardDelayMs + SHARD_DURATION_MS + 40
let animationStart: number | null = null
const shardEntries: Array<{
mesh: THREE.Mesh
outline: THREE.LineLoop
spec: ShardSpec
center: THREE.Vector2
}> = []
shardSpecs.forEach((spec) => {
const { geometry, center, outlinePoints } = createShardMeshData(spec, width, height)
const material = baseMaterial.clone()
material.opacity = 1
const mesh = new THREE.Mesh(geometry, material)
mesh.position.set(center.x, center.y, 0)
mesh.frustumCulled = false
scene.add(mesh)
const lineMaterial = outlineMaterial.clone()
const outlineGeometry = new THREE.BufferGeometry().setFromPoints(outlinePoints)
const outline = new THREE.LineLoop(outlineGeometry, lineMaterial)
outline.position.set(center.x, center.y, 0)
outline.frustumCulled = false
scene.add(outline)
shardEntries.push({ mesh, outline, spec, center })
})
const animate = (timestamp: number) => {
if (animationStart === null) animationStart = timestamp
const elapsed = timestamp - animationStart
shardEntries.forEach(({ mesh, outline, spec, center }) => {
const localElapsed = elapsed - spec.delayMs
const progress = clamp01(localElapsed / SHARD_DURATION_MS)
if (progress <= 0) {
mesh.visible = false
outline.visible = false
return
}
mesh.visible = true
outline.visible = true
const drift = easeOutCubic(progress)
const drop = easeInCubic(progress)
const x = center.x + (spec.translateXPct / 100) * width * drift
const y = center.y - (spec.translateYPct / 100) * height * drop
const rotationZ = THREE.MathUtils.degToRad(spec.rotationDeg * progress + spec.spinZDeg * progress * progress)
const rotationX = THREE.MathUtils.degToRad(spec.tiltXDeg * progress + spec.spinXDeg * progress * progress)
const rotationY = THREE.MathUtils.degToRad(spec.tiltYDeg * progress + spec.spinYDeg * progress * progress)
mesh.position.set(x, y, 0)
mesh.rotation.set(rotationX, rotationY, rotationZ)
;(mesh.material as THREE.MeshBasicMaterial).opacity = 1
outline.position.set(x, y, 0)
outline.rotation.set(rotationX, rotationY, rotationZ)
;(outline.material as THREE.LineBasicMaterial).opacity =
progress < 0.14 ? lerp(0, 0.09, progress / 0.14) : 0.09
})
renderer.render(scene, camera)
if (elapsed < totalDurationMs) {
animationFrameRef.current = window.requestAnimationFrame(animate)
} else {
host.innerHTML = ''
onComplete()
}
}
animationFrameRef.current = window.requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
shardEntries.forEach(({ mesh, outline }) => {
mesh.geometry.dispose()
;(mesh.material as THREE.MeshBasicMaterial).dispose()
outline.geometry.dispose()
;(outline.material as THREE.LineBasicMaterial).dispose()
})
texture.dispose()
renderer.dispose()
host.innerHTML = ''
}
}, [onComplete, snapshotCanvas, stage])
return (
<>
{stage === 'crack' ? (
<div
className={`intro-main-content is-clacking intro-crack-layer${freezeLiveFrame ? ' is-frozen-live' : ''}`}
aria-hidden="true"
>
<IntroCrackGrowth />
</div>
) : null}
<div className={`intro-shatter${stage === 'shatter' ? ' is-active' : ''}`} aria-hidden="true">
<div ref={shatterHostRef} className="intro-shatter-webgl" />
</div>
</>
)
}
export function IntroOverlay({
phase,
onSkip,
onComplete,
}: {
phase: IntroPhase
onSkip: () => void
onComplete: () => void
}) {
const sourceRef = useRef<HTMLDivElement | null>(null)
const [sourceHidden, setSourceHidden] = useState(false)
const isSourceHidden = phase === 'closing' && sourceHidden
if (phase === 'hidden') return null
return (
<motion.div
className={`intro-overlay intro-overlay-${phase}`}
initial={false}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className={`intro-source-shell${isSourceHidden ? ' is-hidden' : ''}`}>
<IntroSource sourceRef={sourceRef} closing={phase === 'closing'} />
</div>
{phase === 'closing' ? (
<ClosingOverlay sourceRef={sourceRef} onComplete={onComplete} onShatterStart={setSourceHidden} />
) : null}
<button type="button" className="intro-skip" onClick={onSkip}>Skip</button>
</motion.div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export function SharedBrand() {
return null
}
+79
View File
@@ -0,0 +1,79 @@
import { useMemo, useState } from 'react'
import { signalNodes, signalSequence } from '../data'
import type { Locale, LocalizedText } from '../types'
function t(locale: Locale, text: LocalizedText) {
return text[locale]
}
function Eyebrow({ children }: { children: string }) {
return <span className="eyebrow">{children}</span>
}
export function SignalMiniGame({ locale }: { locale: Locale }) {
const [step, setStep] = useState(0)
const [wrongId, setWrongId] = useState<string | null>(null)
const completed = step >= signalSequence.length
const activeIds = useMemo<Set<string>>(() => new Set(signalSequence.slice(0, step)), [step])
const currentTip = completed
? t(locale, {
zh: '链路已接通。我们在意的从来不是某一项能力单独突出,而是它们最后能压进同一个项目里。',
en: 'Signal linked. What matters to us is never one isolated strength, but whether everything can finally compress into one real project.',
})
: signalNodes.find((n) => n.id === signalSequence[step])?.note
? t(locale, signalNodes.find((n) => n.id === signalSequence[step])!.note)
: t(locale, {
zh: '按顺序点亮工作室的四个核心环节。',
en: 'Light up the studios four core links in order.',
})
const handleNodeClick = (id: string) => {
if (completed) return
if (id === signalSequence[step]) { setStep((v) => v + 1); setWrongId(null); return }
setWrongId(id)
window.setTimeout(() => setWrongId(null), 320)
}
return (
<div className="signal-module">
<div className="signal-copy">
<Eyebrow>{locale === 'zh' ? 'embedded interaction / signal chain' : 'embedded interaction / signal chain'}</Eyebrow>
<h3>{locale === 'zh' ? '工作室信号链' : 'Studio Signal Chain'}</h3>
<p>
{locale === 'zh'
? '这里保留一个轻量小交互。按顺序点亮创意、叙事、视觉、工程四个节点,它们也是止夜工作室当前最重要的四个压缩面。'
: 'A lightweight interaction stays here. Light up idea, narrative, visual, and engineering in order — the four compressed faces that matter most to the studio right now.'}
</p>
<div className="signal-meta">
<strong>{Math.min(step, signalSequence.length)} / {signalSequence.length}</strong>
<button type="button" className="ghost-button" onClick={() => setStep(0)}>
{locale === 'zh' ? '重置链路' : 'Reset Chain'}
</button>
</div>
<p className="signal-tip">{currentTip}</p>
</div>
<div className="signal-board">
<svg className="signal-lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{signalSequence.slice(0, -1).map((fromId, index) => {
const toId = signalSequence[index + 1]
const from = signalNodes.find((n) => n.id === fromId)!
const to = signalNodes.find((n) => n.id === toId)!
return (
<line key={`${fromId}-${toId}`} x1={from.x} y1={from.y} x2={to.x} y2={to.y}
className={step > index + 1 ? 'signal-line active' : 'signal-line'} />
)
})}
</svg>
{signalNodes.map((node) => (
<button key={node.id} type="button"
className={`signal-node${activeIds.has(node.id) ? ' active' : ''}${wrongId === node.id ? ' wrong' : ''}`}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
onClick={() => handleNodeClick(node.id)} aria-label={locale === 'zh' ? `点亮${t(locale, node.label)}` : `Activate ${t(locale, node.label)}`}>
<span>{t(locale, node.label)}</span>
</button>
))}
{completed ? <div className="signal-complete">{locale === 'zh' ? '链路完成' : 'link complete'}</div> : null}
</div>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
type StudioLogoProps = {
className?: string
title?: string
}
export function StudioLogo({ className, title = '止夜工作室 logo' }: StudioLogoProps) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 515 431"
role="img"
aria-label={title}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M 404 316 L 369 271 L 406 236 L 404 152 L 443 63 L 445 51 L 441 51 L 254 246 L 167 187 L 206 101 L 205 96 L 200 97 L 133 162 L 64 115 L 53 112 L 53 118 L 92 196 L 106 209 L 107 236 L 112 245 L 153 291 L 177 291 L 204 265 L 233 282 L 277 387 L 297 379 L 303 289 L 324 274 L 340 280 L 400 317 Z M 391 175 L 394 230 L 377 246 L 351 247 L 349 239 L 353 233 L 377 233 L 382 229 L 381 189 Z M 124 222 L 129 220 L 138 225 L 141 252 L 153 261 L 163 262 L 178 250 L 185 254 L 172 270 L 152 271 L 127 243 Z"
/>
</svg>
)
}
+230
View File
@@ -0,0 +1,230 @@
import type { HelixLineSpec, IntroSplitColumn, LocalizedText, SignalNode } from './types'
import { assetPath } from './assetPath'
export const studioFacts = [
{
label: { zh: '身份', en: 'identity' },
value: { zh: '校内独立团队', en: 'campus indie team' },
note: { zh: '南阳理工真实长期协作中的项目组。', en: 'A long-term active game team formed inside Nanyang Institute of Technology.' },
},
{
label: { zh: '项目', en: 'project' },
value: { zh: '《希德之钥》', en: "Seed's key" },
note: { zh: '世界观与核心玩法原型已经成型。', en: 'The world setting and core gameplay prototype are already taking shape.' },
},
{
label: { zh: '方式', en: 'workflow' },
value: { zh: '工程化协作', en: 'structured production' },
note: { zh: 'Planka / Gitea / Shrink 系列持续沉淀。', en: 'Our workflow keeps accumulating through tools like Planka, Gitea, and the Shrink stack.' },
},
]
export const teamRoles = [
{
role: { zh: '策划', en: 'design' },
count: { zh: '1人', en: '1' },
note: { zh: '负责项目方向、结构梳理与推进节奏。', en: 'Owns project direction, structure planning, and production rhythm.' },
},
{
role: { zh: '程序', en: 'engineering' },
count: { zh: '2人', en: '2' },
note: { zh: '负责底层架构、玩法实现与工具链维护。', en: 'Owns architecture, gameplay implementation, and tooling maintenance.' },
},
{
role: { zh: '主美', en: 'art lead' },
count: { zh: '1人', en: '1' },
note: { zh: '负责风格制定、角色立绘与 UI 设计。', en: 'Owns visual style, character illustration, and UI direction.' },
},
{
role: { zh: '剧本', en: 'writing' },
count: { zh: '1人', en: '1' },
note: { zh: '负责叙事文本、氛围表达与内容协同。', en: 'Owns narrative text, atmosphere, and content coordination.' },
},
]
export const projectModules = [
{
index: '01',
title: { zh: '世界观搭建', en: 'world framework' },
note: { zh: '不是只有设定词条,而是在为后续叙事、视觉和玩法提供同一套骨架。', en: 'More than scattered lore notes, it is a shared structure for narrative, visuals, and gameplay.' },
},
{
index: '02',
title: { zh: '核心原型', en: 'core prototype' },
note: { zh: '先把真正能玩的部分做出来,再持续打磨节奏、反馈与系统关系。', en: 'We build the playable core first, then keep refining rhythm, feedback, and system relationships.' },
},
{
index: '03',
title: { zh: '内容推进', en: 'content pipeline' },
note: { zh: '素材、文本、视觉和界面正在沿着同一条制作链继续生长。', en: 'Assets, writing, visuals, and UI are growing through the same production chain.' },
},
]
export const workflowPoints = [
{
id: '01',
title: { zh: '需求拆解', en: 'scope breakdown' },
note: { zh: '把含混的想法拆成可以执行、可以交接、可以跟踪的任务切片。', en: 'We break vague ideas into tasks that can be executed, handed off, and tracked.' },
},
{
id: '02',
title: { zh: '接口对接', en: 'discipline alignment' },
note: { zh: '让策划、程序、美术在同一份结构里说同一种语言,减少空转。', en: 'Design, code, and art work inside the same structure so communication stays efficient.' },
},
{
id: '03',
title: { zh: '资源规范', en: 'asset standards' },
note: { zh: '命名、版本、导入和复用链路统一下来,后续迭代才不会失控。', en: 'Naming, versioning, importing, and reuse stay standardized so later iterations remain under control.' },
},
{
id: '04',
title: { zh: '工具沉淀', en: 'tool accumulation' },
note: { zh: '把一次性的开发经验压进可重复使用的底座与流程,而不是只救急一次。', en: 'We turn one-off development experience into reusable foundations instead of solving problems just once.' },
},
]
export const proofCards = [
{
tag: 'release trace',
title: { zh: 'TapTap 公开发布', en: 'TapTap public release' },
note: { zh: 'TapTap 21 天游戏创作挑战作品发布经历,说明我们不是只停在内部演示。', en: 'Our participation in the TapTap 21-Day Game Jam shows we are not limited to internal demos.' },
image: assetPath('assets/taptap-proof.jpg'),
alt: { zh: '止夜工作室 TapTap 发布证明', en: 'No Stay Night Studio TapTap release proof' },
},
{
tag: 'culture archive',
title: { zh: '长期资料积累', en: 'long-term reference archive' },
note: { zh: '资料、灵感和文化素材不是临时拼贴,而是一直在为项目服务的背景储备。', en: 'Reference materials, inspirations, and cultural sources are not temporary collage, but ongoing production reserves.' },
image: assetPath('assets/culture-shelf.jpg'),
alt: { zh: '止夜工作室资料与文化书架陈列', en: 'No Stay Night Studio archive shelf' },
},
{
tag: 'visual archive',
title: { zh: '视觉表达在继续', en: 'visual exploration keeps growing' },
note: { zh: '像素表达、氛围实验与界面资产都在持续累积,不是一次性的概念图。', en: 'Pixel expression, mood studies, and interface assets are all accumulating over time, not one-off concept shots.' },
image: assetPath('assets/pixel-proof.jpg'),
alt: { zh: '止夜工作室像素视觉素材', en: 'No Stay Night Studio pixel visual archive' },
},
{
tag: 'external proof',
title: { zh: '外部奖项验证', en: 'external recognition' },
note: { zh: '东方创意之星·釜山国际艺术节银奖,说明这支团队的表达能力拿出去也站得住。', en: 'Winning silver at the BIACF Busan Art Festival proves our work can stand in external contexts too.' },
image: assetPath('assets/award-proof.jpg'),
alt: { zh: '止夜工作室奖项证明', en: 'No Stay Night Studio award certificate' },
},
]
export const contactInfo = [
{ label: { zh: 'QQ', en: 'QQ' }, value: '3048536893', href: 'tencent://message/?uin=3048536893' },
{ label: { zh: '微信', en: 'WeChat ID' }, value: 'imeicy', href: undefined },
{ label: { zh: '邮箱', en: 'Email' }, value: 'im@crash.work', href: 'mailto:im@crash.work' },
]
export const signalNodes: SignalNode[] = [
{ id: 'idea', label: { zh: '创意', en: 'idea' }, note: { zh: '先有想做的东西,但它不能只停在一句口号。', en: 'Everything starts from a desire to build something, but it cannot stay as a slogan.' }, x: 17, y: 24 },
{ id: 'narrative', label: { zh: '叙事', en: 'narrative' }, note: { zh: '让世界观、角色与情绪真正能被感知。', en: 'Narrative turns world, character, and emotion into something people can actually feel.' }, x: 73, y: 17 },
{ id: 'visual', label: { zh: '视觉', en: 'visuals' }, note: { zh: '让风格、角色与界面形成统一表达。', en: 'Visuals give style, characters, and interfaces a unified voice.' }, x: 79, y: 73 },
{ id: 'engine', label: { zh: '工程', en: 'engineering' }, note: { zh: '最后由工程把它稳定地做出来、推下去。', en: 'Engineering is what turns the idea into something stable and shippable.' }, x: 18, y: 78 },
]
export const signalSequence = ['idea', 'narrative', 'visual', 'engine'] as const
export const introHelixLines: HelixLineSpec[] = [
{ key: '1', width: '92%', offset: '-206px', depth: '-248px', scale: 1.12, tilt: '-9deg', opacity: 0.12 },
{ key: '2', width: '78%', offset: '-166px', depth: '-194px', scale: 1.06, tilt: '-7deg', opacity: 0.16 },
{ key: '3', width: '64%', offset: '-128px', depth: '-146px', scale: 1, tilt: '-5deg', opacity: 0.24 },
{ key: '4', width: '52%', offset: '-90px', depth: '-98px', scale: 0.98, tilt: '-4deg', opacity: 0.32 },
{ key: '5', width: '38%', offset: '-52px', depth: '-54px', scale: 0.94, tilt: '-2deg', opacity: 0.44 },
{ key: '6', width: '22%', offset: '-16px', depth: '-12px', scale: 0.92, tilt: '-1deg', opacity: 0.64 },
{ key: '7', width: '18%', offset: '16px', depth: '12px', scale: 0.92, tilt: '1deg', opacity: 0.88 },
{ key: '8', width: '36%', offset: '50px', depth: '52px', scale: 0.94, tilt: '2deg', opacity: 0.44 },
{ key: '9', width: '50%', offset: '88px', depth: '96px', scale: 0.98, tilt: '4deg', opacity: 0.32 },
{ key: '10', width: '64%', offset: '126px', depth: '144px', scale: 1, tilt: '5deg', opacity: 0.24 },
{ key: '11', width: '78%', offset: '164px', depth: '192px', scale: 1.06, tilt: '7deg', opacity: 0.16 },
{ key: '12', width: '92%', offset: '204px', depth: '246px', scale: 1.12, tilt: '9deg', opacity: 0.12 },
{ key: '13', width: '86%', offset: '-186px', depth: '-220px', scale: 1.1, tilt: '8deg', opacity: 0.08 },
{ key: '14', width: '72%', offset: '-146px', depth: '-168px', scale: 1.04, tilt: '6deg', opacity: 0.11 },
{ key: '15', width: '58%', offset: '-108px', depth: '-120px', scale: 0.99, tilt: '4deg', opacity: 0.16 },
{ key: '16', width: '44%', offset: '-70px', depth: '-76px', scale: 0.96, tilt: '3deg', opacity: 0.22 },
{ key: '17', width: '30%', offset: '-34px', depth: '-32px', scale: 0.93, tilt: '1deg', opacity: 0.32 },
{ key: '18', width: '14%', offset: '0px', depth: '0px', scale: 0.91, tilt: '0deg', opacity: 0.52 },
{ key: '19', width: '26%', offset: '34px', depth: '32px', scale: 0.93, tilt: '-1deg', opacity: 0.32 },
{ key: '20', width: '42%', offset: '70px', depth: '74px', scale: 0.96, tilt: '-3deg', opacity: 0.22 },
{ key: '21', width: '56%', offset: '106px', depth: '118px', scale: 0.99, tilt: '-4deg', opacity: 0.16 },
{ key: '22', width: '70%', offset: '144px', depth: '166px', scale: 1.04, tilt: '-6deg', opacity: 0.11 },
{ key: '23', width: '84%', offset: '182px', depth: '218px', scale: 1.1, tilt: '-8deg', opacity: 0.08 },
{ key: '24', width: '96%', offset: '220px', depth: '264px', scale: 1.14, tilt: '-10deg', opacity: 0.06 },
]
export const introSplitColumns: IntroSplitColumn[] = [
{ key: 'a', left: '0%', width: '22%', delay: 0.12, dir: 1 },
{ key: 'b', left: '20%', width: '18%', delay: 0.04, dir: -1 },
{ key: 'c', left: '36%', width: '28%', delay: 0, dir: 1 },
{ key: 'd', left: '62%', width: '20%', delay: 0.06, dir: -1 },
{ key: 'e', left: '80%', width: '20%', delay: 0.1, dir: 1 },
]
export const reveal = {
initial: { opacity: 0, y: 36 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.82, ease: [0.16, 1, 0.3, 1] as const },
}
export const siteCopy = {
nav: {
about: { zh: '我们是谁', en: 'Who We Are' },
project: { zh: '正在做什么', en: 'What We Build' },
workflow: { zh: '如何工作', en: 'How We Work' },
proof: { zh: '做过什么', en: 'What We Have Done' },
contact: { zh: '联系', en: 'Contact' },
},
hero: {
eyebrow: { zh: 'studio overview', en: 'studio overview' },
title: { zh: '止夜工作室', en: 'No Stay Night Studio' },
tagline: { zh: '把夜里长出来的想法,认真做成可以被体验的作品。', en: 'We turn ideas born at night into works people can truly experience.' },
description: { zh: '我们是南阳理工校内的独立游戏制作团队,正在推进《希德之钥》的开发,也在继续把自己的协作方式、风格判断和项目秩序一点点搭起来。', en: "We are an indie game team inside Nanyang Institute of Technology, currently pushing forward Seed's key while steadily building our own production method, visual judgment, and project order." },
meta: [
{ zh: '南阳理工', en: 'Nanyang Institute of Technology' },
{ zh: '独立游戏', en: 'indie games' },
{ zh: '《希德之钥》', en: "Seed's key" },
] satisfies LocalizedText[],
},
about: {
eyebrow: { zh: 'about / 我们是谁', en: 'about / who we are' },
heading: { zh: '不是临时拼起来的\n兴趣小组,而是一支\n真的在持续推进项目的团队。', en: 'Not a temporary interest group,\nbut a team that is genuinely\npushing a real project forward.' },
statementLead: { zh: '我们从一开始就把"做得出来"和"做得持续"放在同样重要的位置。', en: 'From the beginning, we treated “making it real” and “making it sustainable” as equally important.' },
body: [
{ zh: '比起只停留在点子阶段的团队,我们更在意从企划到实现、从素材到上线的完整链路。每一项工作都要能接进下一项,而不是靠热情临时顶住。', en: 'Rather than staying at the idea stage, we care about the full chain from planning to implementation, from assets to launch. Every piece of work should connect to the next instead of being held together by temporary passion.' },
{ zh: '创意、工程、视觉和叙事,都应该在同一个项目里真正汇合。止夜工作室想做的,不只是"有想法的东西",而是能被认真完成、认真体验的作品。', en: 'Creativity, engineering, visuals, and narrative should genuinely converge inside the same project. What No Stay Night Studio wants to make is not just “something with ideas”, but work that can be seriously completed and seriously experienced.' },
] satisfies LocalizedText[],
},
project: {
eyebrow: { zh: 'project / 我们正在做什么', en: 'project / what we are building' },
title: { zh: '《希德之钥》', en: "Seed's key" },
subtitle: { zh: '二次元 · 模拟经营 · 末日 · 太空', en: 'anime-inspired · management sim · apocalypse · space' },
status: [
{ zh: '世界观成型', en: 'world built' },
{ zh: '原型推进中', en: 'prototype active' },
{ zh: '内容制作中', en: 'content in progress' },
] satisfies LocalizedText[],
},
workflow: {
eyebrow: { zh: 'workflow / 我们如何工作', en: 'workflow / how we work' },
heading: { zh: '创意不是靠热情硬撑下去的,它需要清晰的协作秩序。', en: 'Ideas do not survive on passion alone. They need clear production order.' },
boardTag: { zh: 'board / active', en: 'board / active' },
boardTitle: { zh: '任务看板总览', en: 'task board overview' },
boardAlt: { zh: '止夜工作室任务看板总览', en: 'No Stay Night Studio task board overview' },
},
proof: {
eyebrow: { zh: 'proof / 我们做过什么', en: 'proof / what we have done' },
heading: { zh: '这些不是装饰性的履历墙。', en: 'These are not decorative credentials.' },
previous: { zh: '上一张', en: 'Prev' },
next: { zh: '下一张', en: 'Next' },
railLabel: { zh: '成果轮播', en: 'proof carousel' },
},
contact: {
eyebrow: { zh: 'contact / 联系', en: 'contact / reach us' },
invite: { zh: '如果你想认识我们、交流项目,或者聊聊合作,欢迎直接来找。', en: 'If you want to know us, talk about projects, or explore collaboration, feel free to reach out directly.' },
},
footer: { zh: 'NO STAY NIGHT STUDIO / NANYANG INSTITUTE OF TECHNOLOGY', en: 'NO STAY NIGHT STUDIO / NANYANG INSTITUTE OF TECHNOLOGY' },
}
+3068
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+47
View File
@@ -0,0 +1,47 @@
export type SignalNode = {
id: string
label: LocalizedText
note: LocalizedText
x: number
y: number
}
export type Locale = 'zh' | 'en'
export type LocalizedText = Record<Locale, string>
export type IntroPhase = 'intro' | 'closing' | 'hidden'
export type HelixLineSpec = {
key: string
width: string
offset: string
depth: string
scale: number
tilt: string
opacity: number
}
export type ViewportSize = {
width: number
height: number
}
export type BrandTarget = {
x: number
y: number
scale: number
}
export type BrandAnchors = {
title: BrandTarget
logo: BrandTarget
}
export type IntroSplitColumn = {
key: string
left: string
width: string
delay: number
dir: 1 | -1
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [react()],
})