feat: add contact, index, and work pages with responsive design and functionality
- Implemented a contact page with a form for inquiries and direct links to social profiles. - Created an index page showcasing personal profile, capabilities, featured projects, and tech stack. - Developed dynamic project detail pages with breadcrumb navigation and project-specific information. - Added a projects overview page displaying all projects in a grid layout. - Introduced global styles and typography mixins for consistent design across components.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
---
|
||||
// LinkedIn is no longer distributed by @lucide/astro or simple-icons-astro
|
||||
// (trademark policy). Official glyph reproduced inline, monochrome
|
||||
// via `currentColor` to remain consistent with the other icons.
|
||||
export interface Props {
|
||||
size?: number;
|
||||
}
|
||||
const { size = 18 } = Astro.props;
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<path
|
||||
d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.34V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"
|
||||
></path>
|
||||
</svg>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
export interface Props {
|
||||
index?: string; // ex. "01"
|
||||
label: string;
|
||||
}
|
||||
const { index, label } = Astro.props;
|
||||
---
|
||||
|
||||
<p class="eyebrow">
|
||||
{index && <span class="eyebrow__idx">{index}</span>}
|
||||
<span class="eyebrow__line" aria-hidden="true"></span>
|
||||
<span class="eyebrow__label">{label}</span>
|
||||
</p>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
}
|
||||
.eyebrow__idx {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.eyebrow__line {
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--nds-border-strong);
|
||||
}
|
||||
.eyebrow__label {
|
||||
@include text-eyebrow;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import { Card, Badge, Link } from '@unkn0wndo3s/nova-design-system';
|
||||
import { ArrowUpRight } from '@lucide/astro';
|
||||
import TechIcon from './TechIcon.astro';
|
||||
import { statusMeta, type Project } from '../data/projects';
|
||||
|
||||
export interface Props {
|
||||
project: Project;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const status = statusMeta[project.status];
|
||||
const href = `/work/${project.slug}`;
|
||||
---
|
||||
|
||||
<article class="proj">
|
||||
<Card title={project.title} subtitle={project.tagline}>
|
||||
<div class="proj__meta">
|
||||
<span class="proj__code">{project.designation}</span>
|
||||
<Badge type={status.tone} variant="soft">{status.label}</Badge>
|
||||
</div>
|
||||
<p class="proj__summary">{project.summary}</p>
|
||||
<div class="proj__stack" aria-label="Stack technique">
|
||||
{
|
||||
project.stack.map((s) => (
|
||||
<span class="proj__chip" title={s.label}>
|
||||
<TechIcon name={s.icon} size={15} />
|
||||
{s.label}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div slot="footer" class="proj__footer">
|
||||
<Link url={href}>
|
||||
Voir le projet
|
||||
<ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.proj :global(.card) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--nds-surface) 78%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-color: color-mix(in srgb, var(--nds-border) 90%, transparent);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
.proj :global(.card:hover) {
|
||||
transform: translateY(-3px);
|
||||
border-color: color-mix(in srgb, var(--nds-primary) 45%, var(--nds-border));
|
||||
box-shadow: var(--nds-shadow-lg);
|
||||
}
|
||||
.proj :global(.card__body) {
|
||||
gap: var(--nds-spacing-sm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.proj__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.proj__code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.proj__summary {
|
||||
@include text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin: 0;
|
||||
}
|
||||
.proj__stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin-top: var(--nds-spacing-2xs);
|
||||
}
|
||||
.proj__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 3px var(--nds-spacing-xs);
|
||||
border-radius: var(--nds-radius-full);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.proj__footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import { Gitea, Github, Npm } from 'simple-icons-astro';
|
||||
import { Mail } from '@lucide/astro';
|
||||
import BrandLinkedin from './BrandLinkedin.astro';
|
||||
import { site } from '../data/site';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const socials = [
|
||||
{ ...site.links.gitea, Comp: Gitea },
|
||||
{ ...site.links.github, Comp: Github },
|
||||
{ ...site.links.npm, Comp: Npm },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer__inner">
|
||||
<div class="footer__brand">
|
||||
<span class="footer__mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="footer__name">{site.name}</p>
|
||||
<p class="footer__role">{site.role} · {site.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="footer__links" aria-label="Liens externes">
|
||||
{
|
||||
socials.map((s) => (
|
||||
<a class="footer__link" href={s.url} target="_blank" rel="noopener noreferrer" title={s.label}>
|
||||
<s.Comp size={18} fill="currentColor" />
|
||||
<span>{s.label}</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a class="footer__link" href={site.links.linkedin.url} target="_blank" rel="noopener noreferrer" title="LinkedIn">
|
||||
<BrandLinkedin size={18} />
|
||||
<span>LinkedIn</span>
|
||||
</a>
|
||||
<a class="footer__link" href={`mailto:${site.email}`} title="E-mail">
|
||||
<Mail size={18} />
|
||||
<span>E-mail</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="footer__base">
|
||||
<span>© {year} {site.name}</span>
|
||||
<span class="footer__sep" aria-hidden="true">·</span>
|
||||
<span>Bâti avec Astro + Nova Design System</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'styles/type' as *;
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-top: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--nds-surface) 55%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
margin-top: var(--nds-spacing-3xl);
|
||||
}
|
||||
.footer__inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-lg);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
.footer__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.footer__mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--nds-radius-md);
|
||||
background: linear-gradient(135deg, var(--nds-primary), var(--nds-accent));
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--nds-primary) 45%, transparent);
|
||||
}
|
||||
.footer__name {
|
||||
@include text-xl;
|
||||
margin: 0;
|
||||
}
|
||||
.footer__role {
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
.footer__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
color: var(--nds-neutral);
|
||||
text-decoration: none;
|
||||
@include text-label;
|
||||
transition: color 140ms ease;
|
||||
&:hover {
|
||||
color: var(--nds-primary);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--nds-ring);
|
||||
border-radius: var(--nds-radius-sm);
|
||||
}
|
||||
}
|
||||
.footer__base {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: var(--nds-spacing-md) clamp(20px, 5vw, 56px) var(--nds-spacing-2xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
border-top: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 50%, transparent);
|
||||
@include text-sm;
|
||||
color: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
.footer__sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import { Navbar, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { Send } from '@lucide/astro';
|
||||
|
||||
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const links = [
|
||||
{ href: '/', label: 'Accueil' },
|
||||
{ href: '/work', label: 'Projets' },
|
||||
{ href: '/about', label: 'À propos' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
];
|
||||
const isActive = (href: string) =>
|
||||
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
|
||||
---
|
||||
|
||||
<div class="site-nav">
|
||||
<Navbar brand="L · POTEVIN">
|
||||
{
|
||||
links.map((l) => (
|
||||
<a
|
||||
class={`navbar__link ${isActive(l.href) ? 'navbar__link--active' : ''}`}
|
||||
href={l.href}
|
||||
aria-current={isActive(l.href) ? 'page' : undefined}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<Button slot="right" type="primary" size="sm" href="/contact">
|
||||
<Send slot="icon-left" size={15} />
|
||||
Établir le contact
|
||||
</Button>
|
||||
</Navbar>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 60;
|
||||
|
||||
// Nova Navbar rendered translucent to let the starfield show through.
|
||||
:global(.navbar) {
|
||||
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.1);
|
||||
border-bottom: var(--nds-border-width-thin) solid
|
||||
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.site-nav :global(.navbar__links) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
// Persistent space background. Placed in the layout → loaded on all pages.
|
||||
// Content remains 100% readable and functional even if WebGL fails (decorative).
|
||||
---
|
||||
|
||||
<canvas id="space-canvas" class="space-canvas" aria-hidden="true"></canvas>
|
||||
|
||||
<style lang="scss">
|
||||
.space-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
// slight overlay tint to ground the content on top
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 80% at 50% -10%,
|
||||
color-mix(in srgb, var(--nds-primary) 7%, transparent),
|
||||
transparent 60%
|
||||
),
|
||||
var(--nds-background);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { initScene, type SceneHandle } from './scene/scene';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__novaScene?: SceneHandle;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('space-canvas') as HTMLCanvasElement | null;
|
||||
|
||||
if (canvas && !window.__novaScene) {
|
||||
try {
|
||||
const handle = initScene(canvas);
|
||||
window.__novaScene = handle;
|
||||
// notify page scripts that the scene is ready
|
||||
window.dispatchEvent(new CustomEvent<SceneHandle>('nova:scene-ready', { detail: handle }));
|
||||
} catch (err) {
|
||||
// WebGL unavailable: fail silently, the website remains fully usable.
|
||||
console.warn('[nova] 3D background disabled:', err);
|
||||
canvas.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
// Monochrome tech icon (inherits `color` via fill=currentColor).
|
||||
import {
|
||||
Typescript,
|
||||
Javascript,
|
||||
Postgresql,
|
||||
Docker,
|
||||
Astro as AstroIcon,
|
||||
Sass,
|
||||
Gitea,
|
||||
Linux,
|
||||
Cloudflare,
|
||||
Threedotjs,
|
||||
Rust,
|
||||
Python,
|
||||
Npm,
|
||||
Github,
|
||||
Svelte,
|
||||
Git,
|
||||
Nodedotjs,
|
||||
} from 'simple-icons-astro';
|
||||
|
||||
export interface Props {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
const { name, size = 18 } = Astro.props;
|
||||
|
||||
const map: Record<string, any> = {
|
||||
typescript: Typescript,
|
||||
javascript: Javascript,
|
||||
postgresql: Postgresql,
|
||||
docker: Docker,
|
||||
astro: AstroIcon,
|
||||
sass: Sass,
|
||||
gitea: Gitea,
|
||||
linux: Linux,
|
||||
cloudflare: Cloudflare,
|
||||
threedotjs: Threedotjs,
|
||||
rust: Rust,
|
||||
python: Python,
|
||||
npm: Npm,
|
||||
github: Github,
|
||||
svelte: Svelte,
|
||||
git: Git,
|
||||
nodedotjs: Nodedotjs,
|
||||
};
|
||||
|
||||
const Icon = map[name];
|
||||
---
|
||||
|
||||
{Icon && <Icon size={size} fill="currentColor" />}
|
||||
@@ -0,0 +1,250 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ambient space background: starfield, subtle nebula, and a slow-drifting,
|
||||
// slow-rotating asteroid. No scroll binding, no engine flames.
|
||||
// Slight pointer parallax. Respects prefers-reduced-motion.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SceneHandle {
|
||||
/** Kept for compatibility — no-op (no more impact/flame effect). */
|
||||
setImpact(value: number): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
star: 0xecf5f5,
|
||||
starCool: 0x34c2c9,
|
||||
starAccent: 0x2efafa,
|
||||
nebulaA: 0x288181,
|
||||
nebulaB: 0x1fa6ad,
|
||||
rock: 0x1a2a2a,
|
||||
rim: 0x34c2c9,
|
||||
};
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/** Small reusable radial texture (soft glow). */
|
||||
function radialTexture(inner: string, outer = 'rgba(0,0,0,0)'): THREE.Texture {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 128;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
|
||||
g.addColorStop(0, inner);
|
||||
g.addColorStop(0.4, inner);
|
||||
g.addColorStop(1, outer);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
export function initScene(canvas: HTMLCanvasElement): SceneHandle {
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x040b0b, 0.012);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
400,
|
||||
);
|
||||
camera.position.set(0, 0, 18);
|
||||
|
||||
// ── Lighting (cold, consistent with Nova tokens) ───────────────────────────
|
||||
const ambient = new THREE.AmbientLight(0x16323a, 1.0);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(COLORS.starCool, 0.9);
|
||||
key.position.set(-6, 5, 9);
|
||||
scene.add(key);
|
||||
const rimLight = new THREE.DirectionalLight(COLORS.starAccent, 0.4);
|
||||
rimLight.position.set(8, -3, 4);
|
||||
scene.add(rimLight);
|
||||
|
||||
// ── Starfield ──────────────────────────────────────────────────────────────
|
||||
const STAR_COUNT = prefersReduced ? 700 : 1700;
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
const starPos = new Float32Array(STAR_COUNT * 3);
|
||||
const starCol = new Float32Array(STAR_COUNT * 3);
|
||||
const palette = [
|
||||
new THREE.Color(COLORS.star),
|
||||
new THREE.Color(COLORS.starCool),
|
||||
new THREE.Color(COLORS.starAccent),
|
||||
];
|
||||
for (let i = 0; i < STAR_COUNT; i++) {
|
||||
starPos[i * 3] = (Math.random() - 0.5) * 120;
|
||||
starPos[i * 3 + 1] = (Math.random() - 0.5) * 120;
|
||||
starPos[i * 3 + 2] = (Math.random() - 0.5) * 120 - 20;
|
||||
const col = palette[Math.random() < 0.78 ? 0 : Math.random() < 0.6 ? 1 : 2];
|
||||
starCol[i * 3] = col.r;
|
||||
starCol[i * 3 + 1] = col.g;
|
||||
starCol[i * 3 + 2] = col.b;
|
||||
}
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 0.42,
|
||||
map: radialTexture('rgba(255,255,255,0.95)'),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
vertexColors: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const stars = new THREE.Points(starGeo, starMat);
|
||||
scene.add(stars);
|
||||
|
||||
// ── Nebula (soft sprites, pushed far into the background) ──────────────────
|
||||
const nebula = new THREE.Group();
|
||||
const nebTex = radialTexture('rgba(40,129,129,0.55)');
|
||||
[
|
||||
{ x: -14, y: 8, z: -45, s: 60, c: COLORS.nebulaA },
|
||||
{ x: 16, y: -10, z: -55, s: 72, c: COLORS.nebulaB },
|
||||
{ x: 4, y: 14, z: -40, s: 46, c: COLORS.nebulaA },
|
||||
].forEach((n) => {
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: nebTex,
|
||||
color: n.c,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sp = new THREE.Sprite(mat);
|
||||
sp.position.set(n.x, n.y, n.z);
|
||||
sp.scale.set(n.s, n.s, 1);
|
||||
nebula.add(sp);
|
||||
});
|
||||
scene.add(nebula);
|
||||
|
||||
// ── Asteroid (cold rock, subtle halo) ──────────────────────────────────────
|
||||
const asteroid = new THREE.Group();
|
||||
const rockGeo = new THREE.IcosahedronGeometry(1.9, 2);
|
||||
const pos = rockGeo.attributes.position as THREE.BufferAttribute;
|
||||
const v = new THREE.Vector3();
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
v.fromBufferAttribute(pos, i);
|
||||
const h = Math.sin(v.x * 4.1) * Math.cos(v.y * 3.7) * Math.sin(v.z * 4.3);
|
||||
const d = 1 + h * 0.16;
|
||||
v.multiplyScalar(d);
|
||||
pos.setXYZ(i, v.x, v.y, v.z);
|
||||
}
|
||||
rockGeo.computeVertexNormals();
|
||||
const rockMat = new THREE.MeshStandardMaterial({
|
||||
color: COLORS.rock,
|
||||
emissive: COLORS.rim,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.92,
|
||||
metalness: 0.12,
|
||||
flatShading: true,
|
||||
});
|
||||
const rock = new THREE.Mesh(rockGeo, rockMat);
|
||||
asteroid.add(rock);
|
||||
|
||||
// very light cold halo, just to separate the rock from the background
|
||||
const glow = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: radialTexture('rgba(52,194,201,0.7)'),
|
||||
color: COLORS.rim,
|
||||
transparent: true,
|
||||
opacity: 0.28,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
glow.scale.set(7.5, 7.5, 1);
|
||||
asteroid.add(glow);
|
||||
|
||||
// ambient position: top right, set back
|
||||
asteroid.position.set(6.5, 4.5, -2);
|
||||
scene.add(asteroid);
|
||||
|
||||
// ── State & loop ───────────────────────────────────────────────────────────
|
||||
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||
let running = true;
|
||||
|
||||
function onPointer(e: PointerEvent) {
|
||||
pointer.tx = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
pointer.ty = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
running = document.visibilityState === 'visible';
|
||||
if (running) loop();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('pointermove', onPointer, { passive: true });
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let rafId = 0;
|
||||
|
||||
function loop() {
|
||||
if (!running) return;
|
||||
rafId = requestAnimationFrame(loop);
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
const t = clock.elapsedTime;
|
||||
|
||||
// slow background drift
|
||||
stars.rotation.y += dt * 0.01;
|
||||
nebula.rotation.z += dt * 0.004;
|
||||
|
||||
// asteroid: self-rotation + slight float (no scroll binding)
|
||||
if (!prefersReduced) {
|
||||
rock.rotation.x += dt * 0.18;
|
||||
rock.rotation.y += dt * 0.26;
|
||||
asteroid.position.y = 4.5 + Math.sin(t * 0.25) * 0.5;
|
||||
asteroid.position.x = 6.5 + Math.cos(t * 0.18) * 0.4;
|
||||
}
|
||||
|
||||
// smooth camera parallax via pointer
|
||||
pointer.x += (pointer.tx - pointer.x) * dt * 2;
|
||||
pointer.y += (pointer.ty - pointer.y) * dt * 2;
|
||||
camera.position.x = pointer.x * 1.2;
|
||||
camera.position.y = -pointer.y * 0.8 + Math.sin(t * 0.3) * 0.15;
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
loop();
|
||||
|
||||
return {
|
||||
setImpact() {
|
||||
/* no-op: kept for API compatibility */
|
||||
},
|
||||
destroy() {
|
||||
running = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('pointermove', onPointer);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
starGeo.dispose();
|
||||
rockGeo.dispose();
|
||||
scene.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material;
|
||||
if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
|
||||
else if (m) (m as THREE.Material).dispose();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user