Compare commits
47 Commits
092faa9449
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97240373ca | |||
| eebd87a650 | |||
| ab3bf047d4 | |||
| f1ab2b692d | |||
| 673aabce50 | |||
| b3a5712c85 | |||
| 4691a9fbf4 | |||
| 0fca4db440 | |||
| 930139d1df | |||
| 4120e5ec72 | |||
| fd314cf2ec | |||
| e3ab974988 | |||
| e4a0c57e79 | |||
| ebda4b281e | |||
| 8dcc888d5c | |||
| b7e61b4240 | |||
| 23c39a71a6 | |||
| a0ee50703c | |||
| 8c4080f10c | |||
| b9eeed848b | |||
| d506071ce2 | |||
| cad47f07bd | |||
| 10543bba89 | |||
| aeee2158ba | |||
| 569a4f29fb | |||
| 9a67a800fa | |||
| 719a75d393 | |||
| f583cfdc57 | |||
| 5665804b8f | |||
| 67bf6325fa | |||
| 6d7651dec9 | |||
| ee2eb45527 | |||
| 079986ebec | |||
| a0b416c412 | |||
| c582d6b745 | |||
| beff5e3265 | |||
| c04d8536c0 | |||
| cb15cc3d95 | |||
| 0d72d49d7b | |||
| d66c898f23 | |||
| 566a684bfa | |||
| df81fc1ee0 | |||
| dd5e8a2ae2 | |||
| 20b6a559fd | |||
| eec01440f9 | |||
| 339c660bcb | |||
| fe686b0071 |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,76 +1,88 @@
|
|||||||
.discord-status-compact {
|
.discord-status-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-avatar {
|
.discord-avatar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
box-shadow: 2px 2px 0px var(--pink-accent);
|
box-shadow: 2px 2px 0px var(--pink-accent);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 2px #fffdfd;
|
box-shadow: 0 0 0 2px #fffdfd;
|
||||||
|
}
|
||||||
|
.status-dot.online {
|
||||||
|
background-color: #a7f3d0;
|
||||||
|
border: 1px solid #34d399;
|
||||||
|
}
|
||||||
|
.status-dot.idle {
|
||||||
|
background-color: #fef08a;
|
||||||
|
border: 1px solid #facc15;
|
||||||
|
}
|
||||||
|
.status-dot.dnd {
|
||||||
|
background-color: #fecdd3;
|
||||||
|
border: 1px solid #fb7185;
|
||||||
|
}
|
||||||
|
.status-dot.offline {
|
||||||
|
background-color: var(--text-dim);
|
||||||
|
border: 1px solid var(--text-main);
|
||||||
}
|
}
|
||||||
.status-dot.online { background-color: #a7f3d0; border: 1px solid #34d399; }
|
|
||||||
.status-dot.idle { background-color: #fef08a; border: 1px solid #facc15; }
|
|
||||||
.status-dot.dnd { background-color: #fecdd3; border: 1px solid #fb7185; }
|
|
||||||
.status-dot.offline { background-color: var(--text-dim); border: 1px solid var(--text-main); }
|
|
||||||
|
|
||||||
.status-details {
|
.status-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text em {
|
.status-text em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: var(--text-title);
|
color: var(--text-title);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bubble-inline {
|
.status-bubble-inline {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-icon {
|
.game-icon {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 1px 1px 0px var(--pink-accent);
|
box-shadow: 1px 1px 0px var(--pink-accent);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,262 +1,284 @@
|
|||||||
import './discordstatus.css';
|
import './discordstatus.css';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface LanyardResponse {
|
interface LanyardResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: LanyardData;
|
data: LanyardData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LanyardData {
|
interface LanyardData {
|
||||||
discord_status: 'online' | 'idle' | 'dnd' | 'offline';
|
discord_status: 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
activities: DiscordActivity[];
|
activities: DiscordActivity[];
|
||||||
discord_user: DiscordUser;
|
discord_user: DiscordUser;
|
||||||
active_on_discord_web: boolean;
|
active_on_discord_web: boolean;
|
||||||
active_on_discord_desktop: boolean;
|
active_on_discord_desktop: boolean;
|
||||||
active_on_discord_mobile: boolean;
|
active_on_discord_mobile: boolean;
|
||||||
active_on_discord_embedded: boolean;
|
active_on_discord_embedded: boolean;
|
||||||
active_on_discord_vr: boolean;
|
active_on_discord_vr: boolean;
|
||||||
listening_to_spotify: boolean;
|
listening_to_spotify: boolean;
|
||||||
spotify: SpotifyData | null;
|
spotify: SpotifyData | null;
|
||||||
kv: Record<string, string>;
|
kv: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpotifyData {
|
interface SpotifyData {
|
||||||
album: string;
|
album: string;
|
||||||
album_art_url: string;
|
album_art_url: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
song: string;
|
song: string;
|
||||||
track_id: string;
|
track_id: string;
|
||||||
timestamps: {
|
timestamps: {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscordUser {
|
interface DiscordUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
discriminator: string;
|
discriminator: string;
|
||||||
global_name: string;
|
global_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
public_flags: number;
|
public_flags: number;
|
||||||
avatar_decoration_data: null | any;
|
avatar_decoration_data: null | any;
|
||||||
collectibles?: {
|
collectibles?: {
|
||||||
nameplate?: {
|
nameplate?: {
|
||||||
asset: string;
|
asset: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
palette: string;
|
palette: string;
|
||||||
sku_id: string;
|
sku_id: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
display_name_styles?: {
|
display_name_styles?: {
|
||||||
colors: any[];
|
colors: any[];
|
||||||
effect_id: number;
|
effect_id: number;
|
||||||
font_id: number;
|
font_id: number;
|
||||||
};
|
};
|
||||||
primary_guild?: {
|
primary_guild?: {
|
||||||
badge: null | any;
|
badge: null | any;
|
||||||
identity_enabled: boolean;
|
identity_enabled: boolean;
|
||||||
identity_guild_id: null | string;
|
identity_guild_id: null | string;
|
||||||
tag: null | string;
|
tag: null | string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscordActivity {
|
interface DiscordActivity {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: number;
|
type: number;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
state?: string;
|
state?: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
timestamps?: {
|
timestamps?: {
|
||||||
start?: number;
|
start?: number;
|
||||||
end?: number;
|
end?: number;
|
||||||
};
|
};
|
||||||
assets?: {
|
assets?: {
|
||||||
large_image?: string;
|
large_image?: string;
|
||||||
large_text?: string;
|
large_text?: string;
|
||||||
small_image?: string;
|
small_image?: string;
|
||||||
small_text?: string;
|
small_text?: string;
|
||||||
};
|
};
|
||||||
emoji?: {
|
emoji?: {
|
||||||
name: string;
|
name: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
};
|
};
|
||||||
application_id?: string;
|
application_id?: string;
|
||||||
flags?: number;
|
flags?: number;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
sync_id?: string;
|
sync_id?: string;
|
||||||
content_classification?: {
|
content_classification?: {
|
||||||
data: null | any;
|
data: null | any;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDiscordAsset(applicationId: string | undefined, image: string | undefined): string {
|
function resolveDiscordAsset(
|
||||||
if (!image) return "";
|
applicationId: string | undefined,
|
||||||
|
image: string | undefined
|
||||||
|
): string {
|
||||||
|
if (!image) return '';
|
||||||
|
|
||||||
if (image.startsWith("mp:external/")) {
|
if (image.startsWith('mp:external/')) {
|
||||||
const httpsIndex = image.indexOf("/https/");
|
const httpsIndex = image.indexOf('/https/');
|
||||||
if (httpsIndex !== -1) {
|
if (httpsIndex !== -1) {
|
||||||
return `https://${image.slice(httpsIndex + "/https/".length)}`;
|
return `https://${image.slice(httpsIndex + '/https/'.length)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (image.startsWith("spotify:"))
|
if (image.startsWith('spotify:')) return '';
|
||||||
return "";
|
|
||||||
|
|
||||||
|
if (applicationId && image)
|
||||||
|
return `https://cdn.discordapp.com/app-assets/${applicationId}/${image}.png`;
|
||||||
|
|
||||||
if (applicationId && image)
|
return image;
|
||||||
return `https://cdn.discordapp.com/app-assets/${applicationId}/${image}.png`;
|
|
||||||
|
|
||||||
return image;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordStatusParams {
|
export interface DiscordStatusParams {
|
||||||
userId: string
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<LanyardData['discord_status'], string> = {
|
const STATUS_LABELS: Record<LanyardData['discord_status'], string> = {
|
||||||
online: 'online',
|
online: 'online',
|
||||||
idle: 'away',
|
idle: 'away',
|
||||||
dnd: 'busy',
|
dnd: 'busy',
|
||||||
offline: 'offline'
|
offline: 'offline'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DiscordStatus({ userId }: DiscordStatusParams) {
|
export function DiscordStatus({ userId }: DiscordStatusParams) {
|
||||||
const [presence, setPresence] = useState<LanyardData | null>(null);
|
const [presence, setPresence] = useState<LanyardData | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: NodeJS.Timeout | null = null;
|
let interval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
async function fetchRichPresence() {
|
async function fetchRichPresence() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.lanyard.rest/v1/users/${userId}`);
|
const response = await fetch(
|
||||||
const json: LanyardResponse = await response.json();
|
`https://api.lanyard.rest/v1/users/${userId}`
|
||||||
if (json.success)
|
);
|
||||||
setPresence(json.data);
|
const json: LanyardResponse = await response.json();
|
||||||
} catch (error) {
|
if (json.success) setPresence(json.data);
|
||||||
console.error("Failed to fetch Lanyard presence:", error);
|
} catch (error) {
|
||||||
} finally {
|
console.error('Failed to fetch Lanyard presence:', error);
|
||||||
setLoading(false);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
if (!interval) {
|
if (!interval) {
|
||||||
fetchRichPresence();
|
fetchRichPresence();
|
||||||
interval = setInterval(fetchRichPresence, 30000);
|
interval = setInterval(fetchRichPresence, 30000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
interval = null;
|
interval = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible')
|
if (document.visibilityState === 'visible') startPolling();
|
||||||
startPolling();
|
else stopPolling();
|
||||||
else
|
};
|
||||||
stopPolling();
|
|
||||||
|
|
||||||
};
|
if (document.visibilityState === 'visible') startPolling();
|
||||||
|
else setLoading(false);
|
||||||
|
|
||||||
if (document.visibilityState === 'visible')
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
startPolling();
|
|
||||||
else
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
return () => {
|
if (loading)
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
return (
|
||||||
stopPolling();
|
<p
|
||||||
};
|
style={{
|
||||||
}, [userId]);
|
fontSize: '0.75rem',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--text-dim)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
loading status...
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
if (loading)
|
if (!presence)
|
||||||
return <p style={{ fontSize: '0.75rem', fontStyle: 'italic', color: 'var(--text-dim)' }}>loading status...</p>;
|
return (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--text-dim)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
offline
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
if (!presence)
|
const customActivity = presence.activities.find((act) => act.id === 'custom');
|
||||||
return <p style={{ fontSize: '0.75rem', fontStyle: 'italic', color: 'var(--text-dim)' }}>offline</p>;
|
const customStatusText = customActivity
|
||||||
|
? `${customActivity.emoji?.name || ''} ${customActivity.state || ''}`.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
const customActivity = presence.activities.find(act => act.id === "custom");
|
const gameActivity = presence.activities
|
||||||
const customStatusText = customActivity
|
.filter((act) => act.type === 0)
|
||||||
? `${customActivity.emoji?.name || ''} ${customActivity.state || ''}`.trim()
|
.sort((a, b) => (b.assets ? 1 : 0) - (a.assets ? 1 : 0))[0] as
|
||||||
: null;
|
| DiscordActivity
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const gameActivity = presence.activities
|
const isListeningToSpotify =
|
||||||
.filter(act => act.type === 0)
|
presence.listening_to_spotify && presence.spotify;
|
||||||
.sort((a, b) => (b.assets ? 1 : 0) - (a.assets ? 1 : 0))[0] as DiscordActivity | undefined;
|
|
||||||
|
|
||||||
const isListeningToSpotify = presence.listening_to_spotify && presence.spotify;
|
let primaryActivity = null;
|
||||||
|
let activityText = '';
|
||||||
|
let activityImage = '';
|
||||||
|
|
||||||
let primaryActivity = null;
|
if (gameActivity) {
|
||||||
let activityText = "";
|
primaryActivity = gameActivity;
|
||||||
let activityImage = "";
|
activityText = `playing: ${gameActivity.name.toLowerCase()}`;
|
||||||
|
|
||||||
if (gameActivity) {
|
if (gameActivity.details) activityText += ` • ${gameActivity.details}`;
|
||||||
primaryActivity = gameActivity;
|
|
||||||
activityText = `playing: ${gameActivity.name.toLowerCase()}`;
|
|
||||||
|
|
||||||
if (gameActivity.details)
|
if (gameActivity.assets) {
|
||||||
activityText += ` • ${gameActivity.details}`;
|
const targetImage =
|
||||||
|
gameActivity.assets.small_image || gameActivity.assets.large_image;
|
||||||
|
activityImage = resolveDiscordAsset(
|
||||||
|
gameActivity.application_id,
|
||||||
|
targetImage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (isListeningToSpotify && presence.spotify) {
|
||||||
|
primaryActivity = { name: 'Spotify' } as DiscordActivity;
|
||||||
|
activityText = `listening to: ${presence.spotify.song} • ${presence.spotify.artist}`;
|
||||||
|
activityImage = presence.spotify.album_art_url || '';
|
||||||
|
}
|
||||||
|
|
||||||
if (gameActivity.assets) {
|
return (
|
||||||
const targetImage = gameActivity.assets.small_image || gameActivity.assets.large_image;
|
<div className='discord-status-compact'>
|
||||||
activityImage = resolveDiscordAsset(gameActivity.application_id, targetImage);
|
<div className='avatar-container'>
|
||||||
}
|
<img
|
||||||
}
|
src={`https://api.lanyard.rest/${userId}.png`}
|
||||||
else if (isListeningToSpotify && presence.spotify) {
|
alt='Discord avatar'
|
||||||
primaryActivity = { name: "Spotify" } as DiscordActivity;
|
className='discord-avatar'
|
||||||
activityText = `listening to: ${presence.spotify.song} • ${presence.spotify.artist}`;
|
/>
|
||||||
activityImage = presence.spotify.album_art_url || "";
|
<span className={`status-dot ${presence.discord_status}`} />
|
||||||
}
|
</div>
|
||||||
|
|
||||||
return (
|
<div className='status-details'>
|
||||||
<div className="discord-status-compact">
|
<span className='status-text'>
|
||||||
<div className="avatar-container">
|
{primaryActivity ? (
|
||||||
<img
|
<>{activityText}</>
|
||||||
src={`https://api.lanyard.rest/${userId}.png`}
|
) : (
|
||||||
alt="Discord avatar"
|
<>
|
||||||
className="discord-avatar"
|
currently: <em>{STATUS_LABELS[presence.discord_status]}</em>
|
||||||
/>
|
</>
|
||||||
<span className={`status-dot ${presence.discord_status}`} />
|
)}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<div className="status-details">
|
{customStatusText && (
|
||||||
<span className="status-text">
|
<span className='status-bubble-inline'>{customStatusText}</span>
|
||||||
{primaryActivity ? (
|
)}
|
||||||
<>{activityText}</>
|
</div>
|
||||||
) : (
|
|
||||||
<>currently: <em>{STATUS_LABELS[presence.discord_status]}</em></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{customStatusText && (
|
{activityImage && primaryActivity && (
|
||||||
<span className="status-bubble-inline">
|
<img
|
||||||
{customStatusText}
|
src={activityImage}
|
||||||
</span>
|
alt={primaryActivity.name}
|
||||||
)}
|
className='game-icon'
|
||||||
</div>
|
/>
|
||||||
|
)}
|
||||||
{activityImage && primaryActivity && (
|
</div>
|
||||||
<img
|
);
|
||||||
src={activityImage}
|
|
||||||
alt={primaryActivity.name}
|
|
||||||
className="game-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
.canvas {
|
.canvas {
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
}
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|||||||
+117
-505
@@ -2,523 +2,135 @@
|
|||||||
|
|
||||||
import './page.css';
|
import './page.css';
|
||||||
|
|
||||||
import { PointerLockControls, useTexture } from "@react-three/drei";
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
import {
|
||||||
import { BrightnessContrast, EffectComposer, Noise, Pixelation, Vignette } from "@react-three/postprocessing";
|
BrightnessContrast,
|
||||||
import { Suspense, useEffect, useRef, useState } from "react";
|
EffectComposer,
|
||||||
|
HueSaturation,
|
||||||
|
Noise,
|
||||||
|
Pixelation,
|
||||||
|
Vignette
|
||||||
|
} from '@react-three/postprocessing';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { AmbientSound } from './scene-components/ambient-sound';
|
import { AmbientSound } from './scene-components/ambient-sound';
|
||||||
|
|
||||||
import { fearState, FEAR_SETTINGS } from './state';
|
import { FEAR_SETTINGS, fearState } from './state';
|
||||||
|
|
||||||
function Player() {
|
import TheCreature from './scene-components/creature';
|
||||||
const { camera } = useThree();
|
import Player from './scene-components/player';
|
||||||
const controls = usePlayerControls();
|
import Hallway from './scene-components/hallway';
|
||||||
|
|
||||||
const flashlightRef = useRef<THREE.SpotLight>(null);
|
import { AudioListener } from 'three';
|
||||||
const movementCounter = useRef<number>(0);
|
import FinaleText from './scene-components/finale-text';
|
||||||
|
|
||||||
const forward = new THREE.Vector3();
|
|
||||||
const side = new THREE.Vector3();
|
|
||||||
const direction = new THREE.Vector3();
|
|
||||||
const viewDirection = new THREE.Vector3();
|
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
camera.getWorldDirection(forward);
|
|
||||||
forward.y = 0;
|
|
||||||
forward.normalize();
|
|
||||||
|
|
||||||
side.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
|
|
||||||
|
|
||||||
const moveForward = Number(controls.Forward) - Number(controls.Backward);
|
|
||||||
const moveSide = Number(controls.Right) - Number(controls.Left);
|
|
||||||
|
|
||||||
direction.set(0, 0, 0);
|
|
||||||
if (moveForward !== 0) direction.addScaledVector(forward, moveForward);
|
|
||||||
if (moveSide !== 0) direction.addScaledVector(side, moveSide);
|
|
||||||
|
|
||||||
if (direction.lengthSq() > 0)
|
|
||||||
direction.normalize().multiplyScalar(4 * delta);
|
|
||||||
|
|
||||||
camera.position.x += direction.x;
|
|
||||||
camera.position.z += direction.z;
|
|
||||||
|
|
||||||
const isMoving = controls.Forward || controls.Backward || controls.Left || controls.Right;
|
|
||||||
if (isMoving) {
|
|
||||||
movementCounter.current += delta * 10;
|
|
||||||
camera.position.y = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(movementCounter.current) * 0.08;
|
|
||||||
camera.position.x += Math.cos(movementCounter.current / 2) * 0.006;
|
|
||||||
} else {
|
|
||||||
const breatheTime = state.clock.elapsedTime * 1.5;
|
|
||||||
const breatheY = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(breatheTime) * 0.1;
|
|
||||||
camera.position.y = THREE.MathUtils.lerp(camera.position.y, breatheY, 4 * delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flashlightRef.current) {
|
|
||||||
flashlightRef.current.position.copy(camera.position);
|
|
||||||
camera.getWorldDirection(viewDirection);
|
|
||||||
|
|
||||||
const targetDest = new THREE.Vector3()
|
|
||||||
.copy(camera.position)
|
|
||||||
.addScaledVector(viewDirection, 10);
|
|
||||||
|
|
||||||
flashlightRef.current.target.position.lerp(targetDest, 10 * delta);
|
|
||||||
flashlightRef.current.target.updateMatrixWorld();
|
|
||||||
|
|
||||||
flashlightRef.current.intensity = 5 + Math.sin(state.clock.elapsedTime * 30) * 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
|
|
||||||
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
|
|
||||||
|
|
||||||
if (camera.position.x < minX) camera.position.x = minX;
|
|
||||||
if (camera.position.x > maxX) camera.position.x = maxX;
|
|
||||||
|
|
||||||
if (camera.position.z < -FEAR_SETTINGS.HALLWAY_LENGTH) {
|
|
||||||
camera.position.z += FEAR_SETTINGS.HALLWAY_LENGTH;
|
|
||||||
fearState.registerLoop('forward');
|
|
||||||
}
|
|
||||||
if (camera.position.z > 0) {
|
|
||||||
camera.position.z -= FEAR_SETTINGS.HALLWAY_LENGTH;
|
|
||||||
fearState.registerLoop('backward');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PointerLockControls />
|
|
||||||
<spotLight
|
|
||||||
ref={flashlightRef}
|
|
||||||
distance={22}
|
|
||||||
angle={0.35}
|
|
||||||
penumbra={0.7}
|
|
||||||
intensity={5}
|
|
||||||
color="#fffaed"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePlayerControls() {
|
|
||||||
const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = true;
|
|
||||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = true;
|
|
||||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = true;
|
|
||||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = false;
|
|
||||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = false;
|
|
||||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = false;
|
|
||||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return keys.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoorProps {
|
|
||||||
position: [number, number, number];
|
|
||||||
rotation: [number, number, number];
|
|
||||||
}
|
|
||||||
function Door({ position, rotation }: DoorProps) {
|
|
||||||
return (
|
|
||||||
<group position={position} rotation={rotation}>
|
|
||||||
<mesh position={[0, 2, -0.14]}>
|
|
||||||
<boxGeometry args={[2.4, 4.0, 0.2]} />
|
|
||||||
<meshStandardMaterial color="#8a8585" roughness={0.8} metalness={0.2} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<mesh position={[0, 1.95, -0.08]}>
|
|
||||||
<boxGeometry args={[2.1, 3.8, 0.1]} />
|
|
||||||
<meshStandardMaterial color="#4e4b4b" roughness={0.7} metalness={0.2} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<mesh position={[0.9, 1.8, 0.08]}>
|
|
||||||
<boxGeometry args={[0.08, 0.08, 0.15]} />
|
|
||||||
<meshStandardMaterial color="#4e4b4b" roughness={0.4} metalness={0.2} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Hallway() {
|
|
||||||
const [width, setWidth] = useState(fearState.currentWidth);
|
|
||||||
const [floorTex, wallTex, rustTex] = useTexture([
|
|
||||||
'fear/img/concrete-floor.png',
|
|
||||||
'fear/img/concrete-wall.png',
|
|
||||||
'fear/img/rust.png'
|
|
||||||
]);
|
|
||||||
|
|
||||||
[floorTex, wallTex, rustTex].forEach((tex, index) => {
|
|
||||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
|
||||||
tex.minFilter = tex.magFilter = THREE.NearestFilter;
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
tex.repeat.set(index === 0 ? 1 : 10, index === 0 ? 10 : 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const segments = [0, -1, 1, -2, 2];
|
|
||||||
|
|
||||||
const lightRef1 = useRef<THREE.PointLight>(null);
|
|
||||||
const lightRef2 = useRef<THREE.PointLight>(null);
|
|
||||||
const matRef1 = useRef<THREE.MeshStandardMaterial>(null);
|
|
||||||
const matRef2 = useRef<THREE.MeshStandardMaterial>(null);
|
|
||||||
const lightState = useRef<'normal' | 'flickering' | 'dead'>('normal');
|
|
||||||
const stateEndTime = useRef<number>(0);
|
|
||||||
const nextEventTime = useRef<number>(5);
|
|
||||||
|
|
||||||
const segmentsRef = useRef<THREE.Group[]>([]);
|
|
||||||
|
|
||||||
const wallMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
|
||||||
const floorMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
|
||||||
const pipeMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
|
||||||
const bracketMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
|
||||||
|
|
||||||
wallMaterialsRef.current = [];
|
|
||||||
floorMaterialsRef.current = [];
|
|
||||||
pipeMaterialsRef.current = [];
|
|
||||||
bracketMaterialsRef.current = [];
|
|
||||||
|
|
||||||
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = fearState.subscribe(() => {
|
|
||||||
setWidth(fearState.currentWidth);
|
|
||||||
setIsRustActive(fearState.isRustActive);
|
|
||||||
});
|
|
||||||
return () => unsubscribe();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
const time = state.clock.elapsedTime;
|
|
||||||
|
|
||||||
fearState.update(delta);
|
|
||||||
const currentRust = fearState.isRustActive;
|
|
||||||
if (currentRust !== isRustActive)
|
|
||||||
setIsRustActive(currentRust);
|
|
||||||
|
|
||||||
/*
|
|
||||||
lights
|
|
||||||
*/
|
|
||||||
let intensity1 = 0.85 + Math.sin(time * 2) * 0.03;
|
|
||||||
if (time > nextEventTime.current && lightState.current === 'normal') {
|
|
||||||
lightState.current = 'flickering';
|
|
||||||
stateEndTime.current = time + 1.5 + Math.random() * 2;
|
|
||||||
}
|
|
||||||
if (lightState.current === 'flickering') {
|
|
||||||
if (time > stateEndTime.current) {
|
|
||||||
if (Math.random() > 0.4) {
|
|
||||||
lightState.current = 'dead';
|
|
||||||
stateEndTime.current = time + 1.0 + Math.random() * 2.5;
|
|
||||||
} else {
|
|
||||||
lightState.current = 'normal';
|
|
||||||
nextEventTime.current = time + 10 + Math.random() * 20;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const baseWave = Math.sin(time * 45) * 0.4 + Math.sin(time * 90) * 0.3;
|
|
||||||
intensity1 = 0.5 + baseWave;
|
|
||||||
if (Math.sin(time * 150) + Math.cos(time * 220) > 1.2) intensity1 *= Math.random() > 0.5 ? 0.0 : 0.15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lightState.current === 'dead') {
|
|
||||||
if (time > stateEndTime.current) {
|
|
||||||
lightState.current = 'normal';
|
|
||||||
nextEventTime.current = time + 12 + Math.random() * 15;
|
|
||||||
} else
|
|
||||||
intensity1 = Math.random() > 0.98 ? 0.08 : 0.0;
|
|
||||||
}
|
|
||||||
let intensity2 = lightState.current === 'dead' ? 0 : intensity1 * (0.7 + Math.sin(time * 3) * 0.1);
|
|
||||||
|
|
||||||
if (lightRef1.current) lightRef1.current.intensity = intensity1 * 1.2;
|
|
||||||
if (matRef1.current) {
|
|
||||||
matRef1.current.emissiveIntensity = intensity1 * 2.5;
|
|
||||||
if (lightState.current !== 'normal') matRef1.current.emissive.setHSL(0.07, 0.4, Math.min(intensity1, 0.7));
|
|
||||||
else matRef1.current.emissive.setHex(0xa8a1a1);
|
|
||||||
}
|
|
||||||
if (lightRef2.current) lightRef2.current.intensity = intensity2 * 0.6;
|
|
||||||
if (matRef2.current) matRef2.current.emissiveIntensity = intensity2 * 1.5;
|
|
||||||
|
|
||||||
/*
|
|
||||||
walls
|
|
||||||
*/
|
|
||||||
segmentsRef.current.forEach((segGroup) => {
|
|
||||||
if (!segGroup) return;
|
|
||||||
|
|
||||||
const leftWallGroup = segGroup.getObjectByName("left-wall-group");
|
|
||||||
if (leftWallGroup) leftWallGroup.position.x = -width / 2;
|
|
||||||
|
|
||||||
const rightWallGroup = segGroup.getObjectByName("right-wall-group");
|
|
||||||
if (rightWallGroup) rightWallGroup.position.x = width / 2;
|
|
||||||
|
|
||||||
const floorMesh = segGroup.getObjectByName("floor-mesh");
|
|
||||||
if (floorMesh) floorMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
|
||||||
|
|
||||||
const ceilingMesh = segGroup.getObjectByName("ceiling-mesh");
|
|
||||||
if (ceilingMesh) ceilingMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const pipe = segGroup.getObjectByName(`pipe-${i}`);
|
|
||||||
if (pipe) pipe.position.x = -width / 2 + 0.4 + (i * 0.20);
|
|
||||||
}
|
|
||||||
const bracketGroup = segGroup.getObjectByName("brackets-group");
|
|
||||||
if (bracketGroup) {
|
|
||||||
bracketGroup.children.forEach(b => {
|
|
||||||
b.position.x = -width / 2 + 0.6;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
rust
|
|
||||||
*/
|
|
||||||
wallMaterialsRef.current.forEach(mat => {
|
|
||||||
if (!mat) return;
|
|
||||||
const targetTex = isRustActive ? rustTex : wallTex;
|
|
||||||
if (mat.map !== targetTex) {
|
|
||||||
mat.map = targetTex;
|
|
||||||
mat.needsUpdate = true;
|
|
||||||
}
|
|
||||||
if (isRustActive) {
|
|
||||||
mat.color.set("#918a87");
|
|
||||||
mat.roughness = 0.95;
|
|
||||||
mat.metalness = 0.05;
|
|
||||||
} else {
|
|
||||||
mat.color.set("#ffffff");
|
|
||||||
mat.roughness = 0.7;
|
|
||||||
mat.metalness = 0.1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
floorMaterialsRef.current.forEach(mat => {
|
|
||||||
if (!mat) return;
|
|
||||||
const targetTex = isRustActive ? rustTex : floorTex;
|
|
||||||
if (mat.map !== targetTex) {
|
|
||||||
mat.map = targetTex;
|
|
||||||
mat.needsUpdate = true;
|
|
||||||
}
|
|
||||||
if (isRustActive) {
|
|
||||||
mat.color.set("#8b827f");
|
|
||||||
mat.roughness = 0.95;
|
|
||||||
mat.metalness = 0.05;
|
|
||||||
} else {
|
|
||||||
mat.color.set("#ffffff");
|
|
||||||
mat.roughness = 0.8;
|
|
||||||
mat.metalness = 0.2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeMaterialsRef.current.forEach(mat => {
|
|
||||||
if (!mat) return;
|
|
||||||
mat.color.set(isRustActive ? "#3d1b0f" : "#a5aca8");
|
|
||||||
mat.roughness = isRustActive ? 0.95 : 0.0;
|
|
||||||
mat.metalness = isRustActive ? 0.05 : 0.4;
|
|
||||||
});
|
|
||||||
|
|
||||||
bracketMaterialsRef.current.forEach(mat => {
|
|
||||||
if (!mat) return;
|
|
||||||
mat.color.set(isRustActive ? "#1b0b05" : "#a5aca8");
|
|
||||||
mat.roughness = isRustActive ? 0.95 : 0.0;
|
|
||||||
mat.metalness = isRustActive ? 0.05 : 0.4;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ambientLight intensity={0.0225} />
|
|
||||||
|
|
||||||
{segments.map((segmentValue, index) => (
|
|
||||||
/*
|
|
||||||
lights
|
|
||||||
*/
|
|
||||||
<group
|
|
||||||
key={segmentValue}
|
|
||||||
ref={(el) => { if (el) segmentsRef.current[index] = el; }}
|
|
||||||
position={[0, 0, segmentValue * FEAR_SETTINGS.HALLWAY_LENGTH]}
|
|
||||||
>
|
|
||||||
<group position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.1, -FEAR_SETTINGS.HALLWAY_LENGTH / 4]}>
|
|
||||||
<pointLight
|
|
||||||
ref={segmentValue === 0 ? lightRef1 : null}
|
|
||||||
intensity={0.9}
|
|
||||||
distance={15}
|
|
||||||
color="#a8a1a1"
|
|
||||||
/>
|
|
||||||
<mesh position={[0, 0.09, 0]}>
|
|
||||||
<boxGeometry args={[0.3, 0.01, 0.3]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={segmentValue === 0 ? matRef1 : null}
|
|
||||||
color="#111111"
|
|
||||||
emissive="#a8a1a1"
|
|
||||||
emissiveIntensity={segmentValue === 0 ? 0 : 0.8}
|
|
||||||
roughness={0.9}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{/* floor */}
|
|
||||||
<mesh
|
|
||||||
name="floor-mesh"
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
position={[0, 0, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
|
||||||
>
|
|
||||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && floorMaterialsRef.current.push(el)}
|
|
||||||
map={floorTex}
|
|
||||||
roughness={0.8}
|
|
||||||
metalness={0.2}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* ceiling */}
|
|
||||||
<mesh
|
|
||||||
name="ceiling-mesh"
|
|
||||||
rotation={[Math.PI / 2, 0, 0]}
|
|
||||||
position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
|
||||||
>
|
|
||||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && floorMaterialsRef.current.push(el)}
|
|
||||||
map={floorTex}
|
|
||||||
roughness={0.8}
|
|
||||||
metalness={0.2}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* l wall */}
|
|
||||||
<group name="left-wall-group">
|
|
||||||
<mesh rotation={[0, Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
|
|
||||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && wallMaterialsRef.current.push(el)}
|
|
||||||
map={wallTex}
|
|
||||||
roughness={0.7}
|
|
||||||
metalness={0.1}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
{!isRustActive && (
|
|
||||||
<>
|
|
||||||
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.25]} rotation={[0, Math.PI / 2, 0]} />
|
|
||||||
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.85]} rotation={[0, Math.PI / 2, 0]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{/* r wall */}
|
|
||||||
<group name="right-wall-group">
|
|
||||||
<mesh rotation={[0, -Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
|
|
||||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && wallMaterialsRef.current.push(el)}
|
|
||||||
map={wallTex}
|
|
||||||
roughness={0.7}
|
|
||||||
metalness={0.1}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
{!isRustActive && (
|
|
||||||
<Door position={[-0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.65]} rotation={[0, -Math.PI / 2, 0]} />
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{/* pipes */}
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<mesh
|
|
||||||
key={idx}
|
|
||||||
name={`pipe-${idx}`}
|
|
||||||
rotation={[Math.PI / 2, 0, 0]}
|
|
||||||
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.4 + (idx * 0.20), FEAR_SETTINGS.HALLWAY_HEIGHT - 0.2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
|
||||||
>
|
|
||||||
<cylinderGeometry args={[0.06, 0.06, FEAR_SETTINGS.HALLWAY_LENGTH, 4]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && pipeMaterialsRef.current.push(el)}
|
|
||||||
color="#a5aca8"
|
|
||||||
roughness={0.0}
|
|
||||||
metalness={0.4}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* brackets */}
|
|
||||||
<group name="brackets-group">
|
|
||||||
{Array.from({ length: 5 }).map((_, idx) => {
|
|
||||||
const zOffset = -(idx * 8 + 4);
|
|
||||||
return (
|
|
||||||
<mesh
|
|
||||||
key={`bracket-${idx}`}
|
|
||||||
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.6, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.15, zOffset]}
|
|
||||||
>
|
|
||||||
<boxGeometry args={[0.7, 0.3, 0.15]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
ref={(el) => el && bracketMaterialsRef.current.push(el)}
|
|
||||||
color="#a5aca8"
|
|
||||||
roughness={0.0}
|
|
||||||
metalness={0.4}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PostProcessing() {
|
function PostProcessing() {
|
||||||
return (<EffectComposer>
|
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
|
||||||
<Pixelation granularity={12} />
|
|
||||||
<Vignette />
|
useEffect(() => {
|
||||||
<Noise opacity={0.005} />
|
const unsubscribe = fearState.subscribe(() => {
|
||||||
<BrightnessContrast
|
setWasCaught(fearState.wasCaught);
|
||||||
brightness={-0.01}
|
});
|
||||||
contrast={0.05}
|
return () => unsubscribe();
|
||||||
/>
|
}, []);
|
||||||
</EffectComposer>)
|
|
||||||
|
return (
|
||||||
|
<EffectComposer>
|
||||||
|
<Pixelation granularity={wasCaught ? 18 : 10} />
|
||||||
|
<Vignette />
|
||||||
|
<Noise opacity={wasCaught ? 0.01 : 0.003} />
|
||||||
|
<BrightnessContrast brightness={-0.01} contrast={0.05} />
|
||||||
|
<HueSaturation saturation={wasCaught ? 1 : 0} />
|
||||||
|
</EffectComposer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListenerCreator() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = new AudioListener();
|
||||||
|
camera.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
camera.remove(listener);
|
||||||
|
};
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FearStateUpdater() {
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
fearState.update(delta);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fear() {
|
export default function Fear() {
|
||||||
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
||||||
|
const [wasCaught, setWasCaught] = useState(fearState.isRustActive);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = fearState.subscribe(() => {
|
const unsubscribe = fearState.subscribe(() => {
|
||||||
setIsRustActive(fearState.isRustActive);
|
setIsRustActive(fearState.isRustActive);
|
||||||
});
|
setWasCaught(fearState.wasCaught);
|
||||||
return () => unsubscribe();
|
});
|
||||||
}, []);
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (<>
|
return (
|
||||||
<Canvas
|
<>
|
||||||
shadows
|
<Canvas
|
||||||
gl={{ antialias: true }}
|
shadows
|
||||||
className='canvas'
|
gl={{ antialias: true }}
|
||||||
camera={{ position: [0, 3, -5], fov: 65, far: 100 }}
|
className='canvas'
|
||||||
>
|
camera={{ position: [0, 3, -5], fov: 55, far: 100 }}
|
||||||
<color attach="background" args={['#050505']} />
|
>
|
||||||
|
<FearStateUpdater />
|
||||||
|
|
||||||
<fogExp2 attach='fog' args={[0x050505, 0.035]} />
|
<ListenerCreator />
|
||||||
<PostProcessing />
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Hallway />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<AmbientSound
|
<color attach='background' args={['#050505']} />
|
||||||
url='fear/snd/ambience.mp3'
|
|
||||||
volume={isRustActive ? 0 : 1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Player />
|
{FEAR_SETTINGS.TEST_MODE ? (
|
||||||
</Canvas>
|
<ambientLight intensity={2} />
|
||||||
</>)
|
) : (
|
||||||
}
|
<ambientLight intensity={0.0225} />
|
||||||
|
)}
|
||||||
|
{FEAR_SETTINGS.TEST_MODE ? null : (
|
||||||
|
<fogExp2 attach='fog' args={[0x050505, 0.035]} />
|
||||||
|
)}
|
||||||
|
{FEAR_SETTINGS.TEST_MODE ? null : <PostProcessing />}
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Hallway />
|
||||||
|
<TheCreature />
|
||||||
|
<Player />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<AmbientSound
|
||||||
|
key='ambient-1'
|
||||||
|
url='fear/snd/ambience.mp3'
|
||||||
|
volume={isRustActive ? 0 : 0.5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AmbientSound
|
||||||
|
key='ambient-2'
|
||||||
|
url='fear/snd/ambience2.mp3'
|
||||||
|
volume={isRustActive ? 1 : 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{wasCaught ? (
|
||||||
|
<AmbientSound
|
||||||
|
key='ambient-glitch'
|
||||||
|
url='fear/snd/glitch.mp3'
|
||||||
|
volume={1}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<FinaleText />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +1,67 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface AmbientSoundProps {
|
interface AmbientSoundProps {
|
||||||
url: string
|
url: string;
|
||||||
volume?: number
|
volume?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const targetVolumeRef = useRef<number>(volume);
|
||||||
|
|
||||||
useEffect(() => {
|
targetVolumeRef.current = volume;
|
||||||
const audio = new Audio(url)
|
|
||||||
audio.loop = true
|
|
||||||
audio.volume = volume
|
|
||||||
audioRef.current = audio
|
|
||||||
|
|
||||||
const startAudio = () => {
|
useEffect(() => {
|
||||||
audio.play().catch((err) => {
|
const audio = new Audio(url);
|
||||||
console.warn('Autoplay blocked. Waiting for user interaction.', err)
|
audio.loop = true;
|
||||||
})
|
audio.volume = 0;
|
||||||
}
|
audioRef.current = audio;
|
||||||
|
|
||||||
startAudio()
|
let componentsMounted = true;
|
||||||
|
|
||||||
window.addEventListener('click', startAudio, { once: true })
|
const attemptPlay = () => {
|
||||||
window.addEventListener('keydown', startAudio, { once: true })
|
if (!audioRef.current || !componentsMounted) return;
|
||||||
|
|
||||||
return () => {
|
audio.volume = targetVolumeRef.current;
|
||||||
window.removeEventListener('click', startAudio)
|
|
||||||
window.removeEventListener('keydown', startAudio)
|
|
||||||
audio.pause()
|
|
||||||
audioRef.current = null
|
|
||||||
}
|
|
||||||
}, [url])
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (audio.volume > 0 && audio.paused) {
|
||||||
if (audioRef.current) {
|
audio.play().catch((err) => {
|
||||||
audioRef.current.volume = volume
|
console.warn(
|
||||||
}
|
'Autoplay management holding clip playback execution.',
|
||||||
}, [volume])
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return null
|
attemptPlay();
|
||||||
}
|
|
||||||
|
window.addEventListener('click', attemptPlay);
|
||||||
|
window.addEventListener('keydown', attemptPlay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
componentsMounted = false;
|
||||||
|
window.removeEventListener('click', attemptPlay);
|
||||||
|
window.removeEventListener('keydown', attemptPlay);
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
audioRef.current = null;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
if (volume === 0) {
|
||||||
|
if (!audio.paused) audio.pause();
|
||||||
|
} else {
|
||||||
|
audio.volume = volume;
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useTexture, PositionalAudio } from '@react-three/drei';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { FEAR_SETTINGS, fearState } from '../state';
|
||||||
|
import { ShaderPatch } from '../shader-patch';
|
||||||
|
|
||||||
|
useTexture.preload('fear/img/creature.png');
|
||||||
|
|
||||||
|
export default function TheCreature() {
|
||||||
|
const baseTexture = useTexture('fear/img/creature.png');
|
||||||
|
|
||||||
|
const texture = useMemo(() => {
|
||||||
|
const t = baseTexture.clone();
|
||||||
|
t.needsUpdate = true;
|
||||||
|
return t;
|
||||||
|
}, [baseTexture]);
|
||||||
|
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const audioRef = useRef<THREE.PositionalAudio>(null);
|
||||||
|
const { camera } = useThree();
|
||||||
|
|
||||||
|
const [hasTriggered, setHasTriggered] = useState(false);
|
||||||
|
const [isSpawned, setIsSpawned] = useState(false);
|
||||||
|
|
||||||
|
const globalDistance = useRef<number>(32);
|
||||||
|
const [finaleTriggered, setFinaleTriggered] = useState(
|
||||||
|
fearState.finaleTriggered
|
||||||
|
);
|
||||||
|
|
||||||
|
const audioPlaying = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const movePhase = useRef<'frozen' | 'lurching'>('frozen');
|
||||||
|
const phaseTimer = useRef<number>(1.5);
|
||||||
|
|
||||||
|
const glitchCooldown = useRef<number>(0);
|
||||||
|
const isGlitchSpiking = useRef<boolean>(false);
|
||||||
|
const flickerCooldown = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = fearState.subscribe(() => {
|
||||||
|
setFinaleTriggered(fearState.finaleTriggered);
|
||||||
|
|
||||||
|
if (!fearState.finaleTriggered) {
|
||||||
|
setIsSpawned(false);
|
||||||
|
setHasTriggered(false);
|
||||||
|
globalDistance.current = 32;
|
||||||
|
audioPlaying.current = false;
|
||||||
|
movePhase.current = 'frozen';
|
||||||
|
phaseTimer.current = 1.5;
|
||||||
|
|
||||||
|
if (audioRef.current && audioRef.current.isPlaying)
|
||||||
|
audioRef.current.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
if (!fearState.finaleTriggered) return;
|
||||||
|
|
||||||
|
const creature = meshRef.current;
|
||||||
|
if (!creature) return;
|
||||||
|
|
||||||
|
if (!isSpawned) {
|
||||||
|
setIsSpawned(true);
|
||||||
|
globalDistance.current = 32;
|
||||||
|
movePhase.current = 'frozen';
|
||||||
|
phaseTimer.current = 1.0 + Math.random() * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTriggered) {
|
||||||
|
if (globalDistance.current < 40) setHasTriggered(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTriggered) {
|
||||||
|
phaseTimer.current -= delta;
|
||||||
|
|
||||||
|
if (phaseTimer.current <= 0) {
|
||||||
|
if (movePhase.current === 'frozen') {
|
||||||
|
movePhase.current = 'lurching';
|
||||||
|
phaseTimer.current = 0.05 + Math.random() * 0.2;
|
||||||
|
} else {
|
||||||
|
movePhase.current = 'frozen';
|
||||||
|
const proximityFactor = Math.max(0.05, globalDistance.current / 32);
|
||||||
|
phaseTimer.current = (0.2 + Math.random() * 1.0) * proximityFactor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movePhase.current === 'lurching') {
|
||||||
|
globalDistance.current -= FEAR_SETTINGS.CREATURE_SPEED * 3 * delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioRef.current && !audioPlaying.current) {
|
||||||
|
audioPlaying.current = true;
|
||||||
|
if (audioRef.current.context.state === 'suspended')
|
||||||
|
audioRef.current.context.resume();
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
const shakeIntensity =
|
||||||
|
Math.max(0, 1 - globalDistance.current / 32) * 0.22;
|
||||||
|
camera.position.x += (Math.random() - 0.5) * shakeIntensity;
|
||||||
|
camera.position.y += (Math.random() - 0.5) * shakeIntensity;
|
||||||
|
|
||||||
|
if (globalDistance.current <= 0.1) {
|
||||||
|
window.location.href = '/';
|
||||||
|
fearState.registerCaught();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardVector = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(forwardVector);
|
||||||
|
const lookDirZ = forwardVector.z < 0 ? -1 : 1;
|
||||||
|
|
||||||
|
const calculatedZ = camera.position.z + lookDirZ * globalDistance.current;
|
||||||
|
|
||||||
|
creature.position.set(0, 1.6, calculatedZ);
|
||||||
|
creature.lookAt(camera.position.x, creature.position.y, camera.position.z);
|
||||||
|
|
||||||
|
if (!hasTriggered) return;
|
||||||
|
|
||||||
|
const proximity = 1 - Math.max(0, Math.min(1, globalDistance.current / 32));
|
||||||
|
const jitterX = 1.0 + (Math.random() - 0.5) * 0.04 * proximity;
|
||||||
|
let jitterY = 1.0 + (Math.random() - 0.5) * 0.06 * proximity;
|
||||||
|
|
||||||
|
glitchCooldown.current -= delta;
|
||||||
|
if (glitchCooldown.current <= 0) {
|
||||||
|
if (Math.random() < 0.25 + proximity * 0.35) {
|
||||||
|
isGlitchSpiking.current = true;
|
||||||
|
glitchCooldown.current = 0.03 + Math.random() * 0.08;
|
||||||
|
} else {
|
||||||
|
isGlitchSpiking.current = false;
|
||||||
|
glitchCooldown.current =
|
||||||
|
0.08 + Math.random() * 0.4 * (1 - proximity * 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGlitchSpiking.current) {
|
||||||
|
const spike = 0.15 + Math.random() * 0.35;
|
||||||
|
jitterY += Math.random() > 0.5 ? spike : -spike * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
creature.scale.set(jitterX, jitterY, 1.0);
|
||||||
|
|
||||||
|
flickerCooldown.current -= delta;
|
||||||
|
if (flickerCooldown.current <= 0) {
|
||||||
|
if (creature.visible && Math.random() < 0.12 + proximity * 0.08) {
|
||||||
|
creature.visible = false;
|
||||||
|
flickerCooldown.current = 0.02 + Math.random() * 0.05;
|
||||||
|
} else {
|
||||||
|
creature.visible = true;
|
||||||
|
flickerCooldown.current =
|
||||||
|
0.05 + Math.random() * 0.3 * (1 - proximity * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
texture.offset.set(
|
||||||
|
(Math.random() - 0.5) * 0.025 * proximity,
|
||||||
|
(Math.random() - 0.5) * 0.025 * proximity
|
||||||
|
);
|
||||||
|
|
||||||
|
if (proximity > 0.2) {
|
||||||
|
creature.position.x += (Math.random() - 0.5) * 0.12 * proximity;
|
||||||
|
creature.position.y += (Math.random() - 0.5) * 0.06 * proximity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={meshRef} visible={finaleTriggered}>
|
||||||
|
<planeGeometry args={[3.0, 4.8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={texture}
|
||||||
|
transparent={true}
|
||||||
|
depthWrite={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
emissive='#ffffff'
|
||||||
|
emissiveMap={texture}
|
||||||
|
emissiveIntensity={0.15}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{finaleTriggered && (
|
||||||
|
<PositionalAudio
|
||||||
|
url='fear/snd/riser.mp3'
|
||||||
|
ref={audioRef}
|
||||||
|
distance={25}
|
||||||
|
loop={false}
|
||||||
|
autoplay={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'VCR';
|
||||||
|
src: url('/fear/fonts/vcr.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finale-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0vh;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* filter: invert(100%); */
|
||||||
|
backdrop-filter: brightness(100%);
|
||||||
|
|
||||||
|
grid-auto-rows: 5vh;
|
||||||
|
/* grid-template-columns: 0; */
|
||||||
|
grid-template-rows: repeat(auto-fit, max-content);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
will-change: filter;
|
||||||
|
animation: invertFlicker 0.07s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes invertFlicker {
|
||||||
|
0%,
|
||||||
|
43%,
|
||||||
|
45%,
|
||||||
|
88%,
|
||||||
|
92% {
|
||||||
|
filter: invert(0%) contrast(100%) brightness(100%);
|
||||||
|
backdrop-filter: brightness(100%) hue-rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
44%,
|
||||||
|
46%,
|
||||||
|
89%,
|
||||||
|
93%,
|
||||||
|
100% {
|
||||||
|
filter: invert(100%) contrast(300%) brightness(150%);
|
||||||
|
backdrop-filter: brightness(30%) hue-rotate(180deg) saturate(500%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finale-text {
|
||||||
|
font-family: 'VCR', sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
|
||||||
|
height: 0px;
|
||||||
|
width: 100%;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
font-size: 8vh;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanlines {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 900;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
rgba(0, 0, 0, 0) 0px,
|
||||||
|
rgba(0, 0, 0, 0) 2px,
|
||||||
|
rgba(0, 0, 0, 0.3) 2px,
|
||||||
|
rgba(0, 0, 0, 0.3) 4px
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { JSX, useEffect, useState } from 'react';
|
||||||
|
import { fearState } from '../state';
|
||||||
|
|
||||||
|
import './finale-text.css';
|
||||||
|
|
||||||
|
const BLOCKS = [
|
||||||
|
'▀',
|
||||||
|
'▂',
|
||||||
|
'▃',
|
||||||
|
'▄',
|
||||||
|
'▅',
|
||||||
|
'▆',
|
||||||
|
'▇',
|
||||||
|
'█',
|
||||||
|
'▉',
|
||||||
|
'▊',
|
||||||
|
'▋',
|
||||||
|
'▌',
|
||||||
|
'▍',
|
||||||
|
'▎',
|
||||||
|
'▏',
|
||||||
|
'▐',
|
||||||
|
'░',
|
||||||
|
'▒',
|
||||||
|
'▓',
|
||||||
|
'▔',
|
||||||
|
'▕',
|
||||||
|
'▖',
|
||||||
|
'▗',
|
||||||
|
'▘',
|
||||||
|
'▙',
|
||||||
|
'▚',
|
||||||
|
'▛',
|
||||||
|
'▜',
|
||||||
|
'▝',
|
||||||
|
'▞',
|
||||||
|
'▟'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FinaleText() {
|
||||||
|
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
|
||||||
|
const [elements, setElements] = useState<JSX.Element[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = fearState.subscribe(() => {
|
||||||
|
setWasCaught(fearState.wasCaught);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wasCaught) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (Math.random() > 0.9) return;
|
||||||
|
|
||||||
|
const baseText = 'bwaaaaaaaaa';
|
||||||
|
const corrupted = baseText
|
||||||
|
.split('')
|
||||||
|
.map((char) =>
|
||||||
|
Math.random() > 0.98
|
||||||
|
? BLOCKS[Math.floor(Math.random() * BLOCKS.length)]
|
||||||
|
: char
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
setElements((prev) => [
|
||||||
|
...prev.slice(-30),
|
||||||
|
<span className='finale-text' key={crypto.randomUUID()}>
|
||||||
|
{corrupted}
|
||||||
|
</span>
|
||||||
|
]);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [wasCaught]);
|
||||||
|
|
||||||
|
if (!wasCaught) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='finale-container'>{elements}</div>
|
||||||
|
<div className='scanlines' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FEAR_SETTINGS, fearState } from '../state';
|
||||||
|
import { useTexture, PositionalAudio } from '@react-three/drei';
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { ShaderPatch } from '../shader-patch';
|
||||||
|
|
||||||
|
interface DoorProps {
|
||||||
|
position: [number, number, number];
|
||||||
|
rotation: [number, number, number];
|
||||||
|
}
|
||||||
|
function Door({ position, rotation }: DoorProps) {
|
||||||
|
const [soundUrl, setSoundUrl] = useState<string | null>(null);
|
||||||
|
const currentSound = useRef<string | null>(null);
|
||||||
|
const steelTex = useTexture('fear/img/steel.png');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (Math.random() < 0.02) {
|
||||||
|
const chosenSound =
|
||||||
|
Math.random() < 0.5 ? 'fear/snd/knock1.mp3' : 'fear/snd/knock2.mp3';
|
||||||
|
|
||||||
|
setSoundUrl(chosenSound);
|
||||||
|
currentSound.current = chosenSound;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAudioEnded = () => {
|
||||||
|
setSoundUrl(null);
|
||||||
|
currentSound.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position} rotation={rotation}>
|
||||||
|
{/* frame */}
|
||||||
|
<mesh position={[0, 2, -0.1]}>
|
||||||
|
<boxGeometry args={[2.4, 4.0, 0.2, 4, 4, 1]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={steelTex}
|
||||||
|
color='#8d8d8d'
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* panel */}
|
||||||
|
<mesh position={[0, 1.95, -0.0]}>
|
||||||
|
<boxGeometry args={[2.1, 3.8, 0.1, 4, 4, 1]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={steelTex}
|
||||||
|
color='#4e4a4a'
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* handle */}
|
||||||
|
<mesh position={[0.75, 1.8, 0.085]}>
|
||||||
|
<boxGeometry args={[0.3, 0.08, 0.1]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={steelTex}
|
||||||
|
color='#ffffff'
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{soundUrl && (
|
||||||
|
<PositionalAudio
|
||||||
|
url={soundUrl}
|
||||||
|
distance={25}
|
||||||
|
loop={false}
|
||||||
|
autoplay={true}
|
||||||
|
onEnded={handleAudioEnded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hallway() {
|
||||||
|
const [width, setWidth] = useState(fearState.currentWidth);
|
||||||
|
const [floorTex, wallTex, rustWallTex, rustFloorTex] = useTexture([
|
||||||
|
'fear/img/concrete-floor.png',
|
||||||
|
'fear/img/concrete-wall.png',
|
||||||
|
'fear/img/rust.png',
|
||||||
|
'fear/img/rust.png'
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
[floorTex, wallTex, rustWallTex, rustFloorTex].forEach((tex) => {
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
tex.minFilter = tex.magFilter = THREE.NearestFilter;
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
});
|
||||||
|
}, [floorTex, wallTex, rustWallTex, rustFloorTex]);
|
||||||
|
|
||||||
|
const segmentPool = [0, 1, 2, 3, 4];
|
||||||
|
const segmentCount = segmentPool.length;
|
||||||
|
|
||||||
|
const lightRefs = useRef<(THREE.PointLight | null)[]>([]);
|
||||||
|
const matRefs = useRef<(THREE.MeshStandardMaterial | null)[]>([]);
|
||||||
|
|
||||||
|
const lightState = useRef<'normal' | 'flickering' | 'dead'>('normal');
|
||||||
|
const stateEndTime = useRef<number>(0);
|
||||||
|
const nextEventTime = useRef<number>(5);
|
||||||
|
|
||||||
|
const segmentsRef = useRef<THREE.Group[]>([]);
|
||||||
|
const wallMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||||
|
const floorMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||||
|
const pipeMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||||
|
const bracketMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||||
|
|
||||||
|
wallMaterialsRef.current = [];
|
||||||
|
floorMaterialsRef.current = [];
|
||||||
|
pipeMaterialsRef.current = [];
|
||||||
|
bracketMaterialsRef.current = [];
|
||||||
|
|
||||||
|
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = fearState.subscribe(() => {
|
||||||
|
setWidth(fearState.currentWidth);
|
||||||
|
setIsRustActive(fearState.isRustActive);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
const time = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
/*
|
||||||
|
lights
|
||||||
|
*/
|
||||||
|
let intensity1 = 0.85 + Math.sin(time * 2) * 0.03;
|
||||||
|
if (time > nextEventTime.current && lightState.current === 'normal') {
|
||||||
|
lightState.current = 'flickering';
|
||||||
|
stateEndTime.current = time + 1.5 + Math.random() * 2;
|
||||||
|
}
|
||||||
|
if (lightState.current === 'flickering') {
|
||||||
|
if (time > stateEndTime.current) {
|
||||||
|
if (Math.random() > 0.4) {
|
||||||
|
lightState.current = 'dead';
|
||||||
|
stateEndTime.current = time + 1.0 + Math.random() * 2.5;
|
||||||
|
} else {
|
||||||
|
lightState.current = 'normal';
|
||||||
|
nextEventTime.current = time + 10 + Math.random() * 20;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const baseWave = Math.sin(time * 45) * 0.4 + Math.sin(time * 90) * 0.3;
|
||||||
|
intensity1 = 0.5 + baseWave;
|
||||||
|
if (Math.sin(time * 150) + Math.cos(time * 220) > 1.2)
|
||||||
|
intensity1 *= Math.random() > 0.5 ? 0.0 : 0.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lightState.current === 'dead') {
|
||||||
|
if (time > stateEndTime.current) {
|
||||||
|
lightState.current = 'normal';
|
||||||
|
nextEventTime.current = time + 12 + Math.random() * 15;
|
||||||
|
} else {
|
||||||
|
intensity1 = Math.random() > 0.98 ? 0.08 : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
objects
|
||||||
|
*/
|
||||||
|
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
|
||||||
|
const playerSegmentZ = Math.floor(state.camera.position.z / length);
|
||||||
|
|
||||||
|
const horizontalTexRepeat = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||||
|
floorTex.repeat.set(horizontalTexRepeat, 10);
|
||||||
|
wallTex.repeat.set(10, 1);
|
||||||
|
rustWallTex.repeat.set(10, 1);
|
||||||
|
rustFloorTex.repeat.set(horizontalTexRepeat, 10);
|
||||||
|
|
||||||
|
floorTex.needsUpdate = true;
|
||||||
|
wallTex.needsUpdate = true;
|
||||||
|
rustWallTex.needsUpdate = true;
|
||||||
|
rustFloorTex.needsUpdate = true;
|
||||||
|
|
||||||
|
let closestPoolIndex = 0;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
segmentsRef.current.forEach((segGroup, poolIndex) => {
|
||||||
|
if (!segGroup) return;
|
||||||
|
|
||||||
|
let segmentZIndex =
|
||||||
|
poolIndex - Math.floor(segmentCount / 2) + playerSegmentZ;
|
||||||
|
segGroup.position.z = segmentZIndex * length;
|
||||||
|
|
||||||
|
const distance = Math.abs(segGroup.position.z - state.camera.position.z);
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
closestPoolIndex = poolIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftWallGroup = segGroup.getObjectByName('left-wall-group');
|
||||||
|
if (leftWallGroup) leftWallGroup.position.x = -width / 2;
|
||||||
|
|
||||||
|
const rightWallGroup = segGroup.getObjectByName('right-wall-group');
|
||||||
|
if (rightWallGroup) rightWallGroup.position.x = width / 2;
|
||||||
|
|
||||||
|
const floorMesh = segGroup.getObjectByName('floor-mesh');
|
||||||
|
if (floorMesh) floorMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||||
|
|
||||||
|
const ceilingMesh = segGroup.getObjectByName('ceiling-mesh');
|
||||||
|
if (ceilingMesh)
|
||||||
|
ceilingMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const pipe = segGroup.getObjectByName(`pipe-${i}`);
|
||||||
|
if (pipe) pipe.position.x = -width / 2 + 0.4 + i * 0.2;
|
||||||
|
}
|
||||||
|
const bracketGroup = segGroup.getObjectByName('brackets-group');
|
||||||
|
if (bracketGroup) {
|
||||||
|
bracketGroup.children.forEach((b) => {
|
||||||
|
b.position.x = -width / 2 + 0.6;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
dyn light
|
||||||
|
*/
|
||||||
|
segmentPool.forEach((poolIndex) => {
|
||||||
|
const light = lightRefs.current[poolIndex];
|
||||||
|
const mat = matRefs.current[poolIndex];
|
||||||
|
|
||||||
|
if (poolIndex === closestPoolIndex) {
|
||||||
|
if (light) light.intensity = intensity1 * 1.2;
|
||||||
|
if (mat) {
|
||||||
|
mat.emissiveIntensity = intensity1 * 2.5;
|
||||||
|
if (lightState.current !== 'normal')
|
||||||
|
mat.emissive.setHSL(0.07, 0.4, Math.min(intensity1, 0.7));
|
||||||
|
else mat.emissive.setHex(0xa8a1a1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (light) light.intensity = 0.9;
|
||||||
|
if (mat) {
|
||||||
|
mat.emissiveIntensity = 0.8;
|
||||||
|
mat.emissive.setHex(0xa8a1a1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
materials
|
||||||
|
*/
|
||||||
|
const updateMaterials = (
|
||||||
|
materials: THREE.MeshStandardMaterial[],
|
||||||
|
defaultTex: THREE.Texture,
|
||||||
|
targetRustTex: THREE.Texture,
|
||||||
|
activeColor: string,
|
||||||
|
defaultColor: string,
|
||||||
|
activeRough: number,
|
||||||
|
defaultRough: number,
|
||||||
|
activeMetal: number,
|
||||||
|
defaultMetal: number
|
||||||
|
) => {
|
||||||
|
materials.forEach((mat) => {
|
||||||
|
if (!mat) return;
|
||||||
|
const targetTex = isRustActive ? targetRustTex : defaultTex;
|
||||||
|
if (mat.map !== targetTex) {
|
||||||
|
mat.map = targetTex;
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
}
|
||||||
|
mat.color.set(isRustActive ? activeColor : defaultColor);
|
||||||
|
mat.roughness = isRustActive ? activeRough : defaultRough;
|
||||||
|
mat.metalness = isRustActive ? activeMetal : defaultMetal;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMaterials(
|
||||||
|
wallMaterialsRef.current,
|
||||||
|
wallTex,
|
||||||
|
rustWallTex,
|
||||||
|
'#c5c0be',
|
||||||
|
'#ffffff',
|
||||||
|
0.95,
|
||||||
|
0.7,
|
||||||
|
0.05,
|
||||||
|
0.1
|
||||||
|
);
|
||||||
|
updateMaterials(
|
||||||
|
floorMaterialsRef.current,
|
||||||
|
floorTex,
|
||||||
|
rustFloorTex,
|
||||||
|
'#cabdb9',
|
||||||
|
'#ffffff',
|
||||||
|
0.95,
|
||||||
|
0.8,
|
||||||
|
0.05,
|
||||||
|
0.2
|
||||||
|
);
|
||||||
|
|
||||||
|
pipeMaterialsRef.current.forEach((mat) => {
|
||||||
|
if (!mat) return;
|
||||||
|
mat.color.set(isRustActive ? '#3d1b0f' : '#a5aca8');
|
||||||
|
mat.roughness = isRustActive ? 0.95 : 0.0;
|
||||||
|
mat.metalness = isRustActive ? 0.05 : 0.4;
|
||||||
|
});
|
||||||
|
|
||||||
|
bracketMaterialsRef.current.forEach((mat) => {
|
||||||
|
if (!mat) return;
|
||||||
|
mat.color.set(isRustActive ? '#1b0b05' : '#a5aca8');
|
||||||
|
mat.roughness = isRustActive ? 0.95 : 0.0;
|
||||||
|
mat.metalness = isRustActive ? 0.05 : 0.4;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segmentPool.map((poolIndex) => (
|
||||||
|
<group
|
||||||
|
key={poolIndex}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) segmentsRef.current[poolIndex] = el;
|
||||||
|
}}
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
>
|
||||||
|
{/* lights */}
|
||||||
|
<group
|
||||||
|
position={[
|
||||||
|
0,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT - 0.1,
|
||||||
|
-FEAR_SETTINGS.HALLWAY_LENGTH / 4
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<pointLight
|
||||||
|
ref={(el) => {
|
||||||
|
lightRefs.current[poolIndex] = el;
|
||||||
|
}}
|
||||||
|
intensity={0.9}
|
||||||
|
distance={15}
|
||||||
|
color='#a8a1a1'
|
||||||
|
/>
|
||||||
|
<mesh position={[0, 0.09, 0]}>
|
||||||
|
<boxGeometry args={[0.3, 0.01, 0.3]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => {
|
||||||
|
matRefs.current[poolIndex] = el;
|
||||||
|
}}
|
||||||
|
color='#111111'
|
||||||
|
emissive='#a8a1a1'
|
||||||
|
emissiveIntensity={0.8}
|
||||||
|
roughness={0.9}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* floor */}
|
||||||
|
<mesh
|
||||||
|
name='floor-mesh'
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
position={[0, 0, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
||||||
|
>
|
||||||
|
<planeGeometry
|
||||||
|
args={[
|
||||||
|
FEAR_SETTINGS.HALLWAY_WIDTH,
|
||||||
|
FEAR_SETTINGS.HALLWAY_LENGTH,
|
||||||
|
4,
|
||||||
|
10
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) floorMaterialsRef.current.push(el);
|
||||||
|
}}
|
||||||
|
map={floorTex}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* ceiling */}
|
||||||
|
<mesh
|
||||||
|
name='ceiling-mesh'
|
||||||
|
rotation={[Math.PI / 2, 0, 0]}
|
||||||
|
position={[
|
||||||
|
0,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT,
|
||||||
|
-FEAR_SETTINGS.HALLWAY_LENGTH / 2
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<planeGeometry
|
||||||
|
args={[
|
||||||
|
FEAR_SETTINGS.HALLWAY_WIDTH,
|
||||||
|
FEAR_SETTINGS.HALLWAY_LENGTH,
|
||||||
|
4,
|
||||||
|
10
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) floorMaterialsRef.current.push(el);
|
||||||
|
}}
|
||||||
|
map={floorTex}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* left wall */}
|
||||||
|
<group name='left-wall-group'>
|
||||||
|
<mesh
|
||||||
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
|
position={[
|
||||||
|
0,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT / 2,
|
||||||
|
-FEAR_SETTINGS.HALLWAY_LENGTH / 2
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<planeGeometry
|
||||||
|
args={[
|
||||||
|
FEAR_SETTINGS.HALLWAY_LENGTH,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT,
|
||||||
|
10,
|
||||||
|
4
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) wallMaterialsRef.current.push(el);
|
||||||
|
}}
|
||||||
|
map={wallTex}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{!isRustActive && (
|
||||||
|
<>
|
||||||
|
<Door
|
||||||
|
position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.25]}
|
||||||
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
|
/>
|
||||||
|
<Door
|
||||||
|
position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.85]}
|
||||||
|
rotation={[0, Math.PI / 2, 0]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* right wall */}
|
||||||
|
<group name='right-wall-group'>
|
||||||
|
<mesh
|
||||||
|
rotation={[0, -Math.PI / 2, 0]}
|
||||||
|
position={[
|
||||||
|
0,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT / 2,
|
||||||
|
-FEAR_SETTINGS.HALLWAY_LENGTH / 2
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<planeGeometry
|
||||||
|
args={[
|
||||||
|
FEAR_SETTINGS.HALLWAY_LENGTH,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT,
|
||||||
|
10,
|
||||||
|
4
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) wallMaterialsRef.current.push(el);
|
||||||
|
}}
|
||||||
|
map={wallTex}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{!isRustActive && (
|
||||||
|
<Door
|
||||||
|
position={[-0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.65]}
|
||||||
|
rotation={[0, -Math.PI / 2, 0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* pipes */}
|
||||||
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
|
<mesh
|
||||||
|
key={idx}
|
||||||
|
name={`pipe-${idx}`}
|
||||||
|
rotation={[Math.PI / 2, 0, 0]}
|
||||||
|
position={[
|
||||||
|
-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.4 + idx * 0.2,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT - 0.2,
|
||||||
|
-FEAR_SETTINGS.HALLWAY_LENGTH / 2
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<cylinderGeometry
|
||||||
|
args={[0.06, 0.06, FEAR_SETTINGS.HALLWAY_LENGTH, 4]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => el && pipeMaterialsRef.current.push(el)}
|
||||||
|
color='#a5aca8'
|
||||||
|
roughness={0.0}
|
||||||
|
metalness={0.4}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* brackets */}
|
||||||
|
<group name='brackets-group'>
|
||||||
|
{Array.from({ length: 5 }).map((_, idx) => {
|
||||||
|
const zOffset = -(idx * 8 + 4);
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={`bracket-${idx}`}
|
||||||
|
position={[
|
||||||
|
-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.6,
|
||||||
|
FEAR_SETTINGS.HALLWAY_HEIGHT - 0.15,
|
||||||
|
zOffset
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.7, 0.3, 0.15]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => el && bracketMaterialsRef.current.push(el)}
|
||||||
|
color='#a5aca8'
|
||||||
|
roughness={0.0}
|
||||||
|
metalness={0.4}
|
||||||
|
onBeforeCompile={ShaderPatch}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { FEAR_SETTINGS, fearState } from '../state';
|
||||||
|
import { PointerLockControls } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const forward = new THREE.Vector3();
|
||||||
|
const side = new THREE.Vector3();
|
||||||
|
const viewDirection = new THREE.Vector3();
|
||||||
|
const targetDest = new THREE.Vector3();
|
||||||
|
|
||||||
|
const playerRoot = new THREE.Vector3(0, FEAR_SETTINGS.PLAYER_HEIGHT, 0);
|
||||||
|
const targetVelocity = new THREE.Vector3();
|
||||||
|
const currentVelocity = new THREE.Vector3();
|
||||||
|
|
||||||
|
function usePlayerControls() {
|
||||||
|
const keys = useRef({
|
||||||
|
Forward: false,
|
||||||
|
Backward: false,
|
||||||
|
Left: false,
|
||||||
|
Right: false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'KeyW' || e.code === 'ArrowUp')
|
||||||
|
keys.current.Forward = true;
|
||||||
|
if (e.code === 'KeyS' || e.code === 'ArrowDown')
|
||||||
|
keys.current.Backward = true;
|
||||||
|
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = true;
|
||||||
|
if (e.code === 'KeyD' || e.code === 'ArrowRight')
|
||||||
|
keys.current.Right = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'KeyW' || e.code === 'ArrowUp')
|
||||||
|
keys.current.Forward = false;
|
||||||
|
if (e.code === 'KeyS' || e.code === 'ArrowDown')
|
||||||
|
keys.current.Backward = false;
|
||||||
|
if (e.code === 'KeyA' || e.code === 'ArrowLeft')
|
||||||
|
keys.current.Left = false;
|
||||||
|
if (e.code === 'KeyD' || e.code === 'ArrowRight')
|
||||||
|
keys.current.Right = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return keys.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Player() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
const controls = usePlayerControls();
|
||||||
|
|
||||||
|
const flashlightRef = useRef<THREE.SpotLight>(null);
|
||||||
|
const movementCounter = useRef<number>(0);
|
||||||
|
const bobIntensity = useRef<number>(0);
|
||||||
|
|
||||||
|
const confirmedSegment = useRef<number>(0);
|
||||||
|
const hasTriggeredThisSegment = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const footstepAudio = useRef<HTMLAudioElement[]>([]);
|
||||||
|
const hasStepped = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
playerRoot.set(
|
||||||
|
camera.position.x,
|
||||||
|
FEAR_SETTINGS.PLAYER_HEIGHT,
|
||||||
|
camera.position.z
|
||||||
|
);
|
||||||
|
footstepAudio.current = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const audio = new Audio(`fear/snd/footstep${i + 1}.mp3`);
|
||||||
|
audio.volume = 0.4;
|
||||||
|
return audio;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playRandomFootstep = () => {
|
||||||
|
if (footstepAudio.current.length === 0) return;
|
||||||
|
|
||||||
|
const randomIndex = Math.floor(
|
||||||
|
Math.random() * footstepAudio.current.length
|
||||||
|
);
|
||||||
|
const audio = footstepAudio.current[randomIndex];
|
||||||
|
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.play().catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
'Footstep playback blocked by browser autocomplete/interaction rules.',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
const dt = Math.min(delta, 0.1);
|
||||||
|
|
||||||
|
camera.getWorldDirection(forward);
|
||||||
|
forward.y = 0;
|
||||||
|
forward.normalize();
|
||||||
|
side.crossVectors(forward, THREE.Object3D.DEFAULT_UP).normalize();
|
||||||
|
|
||||||
|
const moveForward = Number(controls.Forward) - Number(controls.Backward);
|
||||||
|
const moveSide = Number(controls.Right) - Number(controls.Left);
|
||||||
|
|
||||||
|
targetVelocity.set(0, 0, 0);
|
||||||
|
if (moveForward !== 0) targetVelocity.addScaledVector(forward, moveForward);
|
||||||
|
if (moveSide !== 0) targetVelocity.addScaledVector(side, moveSide);
|
||||||
|
|
||||||
|
if (targetVelocity.lengthSq() > 0)
|
||||||
|
targetVelocity.normalize().multiplyScalar(FEAR_SETTINGS.PLAYER_SPEED);
|
||||||
|
|
||||||
|
currentVelocity.lerp(targetVelocity, 10 * dt);
|
||||||
|
|
||||||
|
playerRoot.x += currentVelocity.x * dt;
|
||||||
|
playerRoot.z += currentVelocity.z * dt;
|
||||||
|
|
||||||
|
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
|
||||||
|
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
|
||||||
|
playerRoot.x = THREE.MathUtils.clamp(playerRoot.x, minX, maxX);
|
||||||
|
|
||||||
|
const isMoving =
|
||||||
|
controls.Forward || controls.Backward || controls.Left || controls.Right;
|
||||||
|
|
||||||
|
bobIntensity.current = THREE.MathUtils.lerp(
|
||||||
|
bobIntensity.current,
|
||||||
|
isMoving ? 1 : 0,
|
||||||
|
8 * dt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMoving) movementCounter.current += dt * 12;
|
||||||
|
|
||||||
|
const sinWave = Math.sin(movementCounter.current);
|
||||||
|
const moveBobY = sinWave * 0.06 * bobIntensity.current;
|
||||||
|
const moveBobX =
|
||||||
|
Math.cos(movementCounter.current / 2) * 0.04 * bobIntensity.current;
|
||||||
|
|
||||||
|
if (isMoving && sinWave < -0.9) {
|
||||||
|
if (!hasStepped.current) {
|
||||||
|
playRandomFootstep();
|
||||||
|
hasStepped.current = true;
|
||||||
|
}
|
||||||
|
} else if (sinWave > 0) {
|
||||||
|
hasStepped.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const breatheTime = state.clock.elapsedTime * 1.8;
|
||||||
|
const breatheBobY =
|
||||||
|
Math.sin(breatheTime) * 0.03 * (1 - bobIntensity.current * 0.5);
|
||||||
|
|
||||||
|
camera.position.copy(playerRoot);
|
||||||
|
camera.position.y += moveBobY + breatheBobY;
|
||||||
|
camera.position.addScaledVector(side, moveBobX);
|
||||||
|
|
||||||
|
if (flashlightRef.current) {
|
||||||
|
flashlightRef.current.position.lerp(camera.position, 7 * dt);
|
||||||
|
camera.getWorldDirection(viewDirection);
|
||||||
|
|
||||||
|
targetDest.copy(camera.position).addScaledVector(viewDirection, 10);
|
||||||
|
|
||||||
|
flashlightRef.current.target.position.lerp(targetDest, 12 * dt);
|
||||||
|
flashlightRef.current.target.updateMatrixWorld();
|
||||||
|
|
||||||
|
flashlightRef.current.intensity =
|
||||||
|
FEAR_SETTINGS.FLASHLIGHT_INTENSITY_BASE +
|
||||||
|
Math.sin(state.clock.elapsedTime * 30) *
|
||||||
|
0.15 *
|
||||||
|
Math.cos(state.clock.elapsedTime * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
|
||||||
|
const absoluteZ = -playerRoot.z;
|
||||||
|
const rawSegmentIndex = Math.floor(absoluteZ / length);
|
||||||
|
const progressZ = (((absoluteZ % length) + length) % length) / length;
|
||||||
|
|
||||||
|
if (rawSegmentIndex > confirmedSegment.current && progressZ > 0.25) {
|
||||||
|
if (!hasTriggeredThisSegment.current) {
|
||||||
|
fearState.registerLoop('forward');
|
||||||
|
hasTriggeredThisSegment.current = true;
|
||||||
|
}
|
||||||
|
confirmedSegment.current = rawSegmentIndex;
|
||||||
|
} else if (rawSegmentIndex < confirmedSegment.current && progressZ < 0.75) {
|
||||||
|
if (!hasTriggeredThisSegment.current) {
|
||||||
|
fearState.registerLoop('backward');
|
||||||
|
hasTriggeredThisSegment.current = true;
|
||||||
|
}
|
||||||
|
confirmedSegment.current = rawSegmentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawSegmentIndex === confirmedSegment.current &&
|
||||||
|
progressZ > 0.35 &&
|
||||||
|
progressZ < 0.65
|
||||||
|
) {
|
||||||
|
hasTriggeredThisSegment.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PointerLockControls />
|
||||||
|
<spotLight
|
||||||
|
ref={flashlightRef}
|
||||||
|
distance={25}
|
||||||
|
angle={0.35}
|
||||||
|
penumbra={0.8}
|
||||||
|
intensity={0}
|
||||||
|
color='#fffaed'
|
||||||
|
decay={2}
|
||||||
|
castShadow
|
||||||
|
shadow-bias={-0.001}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export function ShaderPatch(shader: {
|
||||||
|
vertexShader: string;
|
||||||
|
fragmentShader: string;
|
||||||
|
uniforms: Object;
|
||||||
|
}) {
|
||||||
|
shader.vertexShader = `
|
||||||
|
varying float vDepth;
|
||||||
|
#ifdef USE_MAP
|
||||||
|
varying vec2 vAffineUv;
|
||||||
|
#endif
|
||||||
|
${shader.vertexShader}
|
||||||
|
`;
|
||||||
|
|
||||||
|
shader.vertexShader = shader.vertexShader.replace(
|
||||||
|
`#include <project_vertex>`,
|
||||||
|
`
|
||||||
|
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
||||||
|
gl_Position = projectionMatrix * mvPosition;
|
||||||
|
|
||||||
|
float precisionModifier = 200.0;
|
||||||
|
gl_Position.xy /= gl_Position.w;
|
||||||
|
gl_Position.xy = floor(gl_Position.xy * precisionModifier) / precisionModifier;
|
||||||
|
gl_Position.xy *= gl_Position.w;
|
||||||
|
|
||||||
|
vDepth = gl_Position.w;
|
||||||
|
|
||||||
|
#ifdef USE_MAP
|
||||||
|
vAffineUv = vMapUv * gl_Position.w;
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
shader.fragmentShader = `
|
||||||
|
varying float vDepth;
|
||||||
|
#ifdef USE_MAP
|
||||||
|
varying vec2 vAffineUv;
|
||||||
|
#endif
|
||||||
|
${shader.fragmentShader}
|
||||||
|
`;
|
||||||
|
|
||||||
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
|
`#include <map_fragment>`,
|
||||||
|
`
|
||||||
|
#ifdef USE_MAP
|
||||||
|
vec2 flatAffineUV = vAffineUv / max(vDepth, 0.001);
|
||||||
|
|
||||||
|
vec2 warpDiff = flatAffineUV - vMapUv;
|
||||||
|
float warpDist = length(warpDiff);
|
||||||
|
|
||||||
|
float maxDistortion = 0.25;
|
||||||
|
|
||||||
|
float falloff = maxDistortion / (maxDistortion + warpDist);
|
||||||
|
|
||||||
|
vec2 finalUV = vMapUv + (warpDiff * falloff);
|
||||||
|
|
||||||
|
vec4 texelColor = texture2D( map, finalUV );
|
||||||
|
diffuseColor *= texelColor;
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
+72
-30
@@ -1,44 +1,86 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
export const FEAR_SETTINGS = {
|
export const FEAR_SETTINGS = {
|
||||||
HALLWAY_LENGTH: 40,
|
HALLWAY_LENGTH: 40,
|
||||||
HALLWAY_WIDTH: 6,
|
HALLWAY_WIDTH: 6,
|
||||||
HALLWAY_HEIGHT: 5,
|
HALLWAY_HEIGHT: 5,
|
||||||
PLAYER_HEIGHT: 3,
|
PLAYER_HEIGHT: 3,
|
||||||
WALL_BUFFER: 0.6,
|
PLAYER_SPEED: 4,
|
||||||
|
FLASHLIGHT_INTENSITY_BASE: 8,
|
||||||
|
WALL_BUFFER: 0.6,
|
||||||
|
CREATURE_SPEED: 8,
|
||||||
|
|
||||||
|
EVENT_NARROW_LOOP_COUNT: 2,
|
||||||
|
EVENT_RUST_LOOP_COUNT: 4,
|
||||||
|
EVENT_FINALE_LOOP_COUNT: 5,
|
||||||
|
|
||||||
|
EVENT_FINALE_DURATION: 1,
|
||||||
|
|
||||||
|
TEST_MODE: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
export const fearState = {
|
export const fearState = {
|
||||||
loopCount: 0,
|
loopCount: 0,
|
||||||
currentWidth: FEAR_SETTINGS.HALLWAY_WIDTH,
|
currentWidth: FEAR_SETTINGS.HALLWAY_WIDTH,
|
||||||
isRustActive: false,
|
isRustActive: false,
|
||||||
|
finaleTriggered: false,
|
||||||
|
wasCaught: false,
|
||||||
|
finaleProgression: 0,
|
||||||
|
|
||||||
subscribe(listener: () => void) {
|
subscribe(listener: () => void) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => { listeners.delete(listener); };
|
return () => {
|
||||||
},
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
emit() {
|
emit() {
|
||||||
listeners.forEach((listener) => listener());
|
listeners.forEach((listener) => listener());
|
||||||
},
|
},
|
||||||
|
|
||||||
update(delta: number) {
|
update(delta: number) {
|
||||||
this.isRustActive = this.loopCount >= 3;
|
const targetWidth =
|
||||||
|
this.loopCount >= FEAR_SETTINGS.EVENT_NARROW_LOOP_COUNT
|
||||||
|
? 2.5
|
||||||
|
: FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||||
|
const newWidth = THREE.MathUtils.lerp(
|
||||||
|
this.currentWidth,
|
||||||
|
targetWidth,
|
||||||
|
2 * delta
|
||||||
|
);
|
||||||
|
|
||||||
const targetWidth = this.loopCount >= 2 ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
|
if (Math.abs(this.currentWidth - newWidth) > 0.001) {
|
||||||
const newWidth = THREE.MathUtils.lerp(this.currentWidth, targetWidth, 2 * delta);
|
this.currentWidth = newWidth;
|
||||||
|
}
|
||||||
|
|
||||||
if (Math.abs(this.currentWidth - newWidth) > 0.001) {
|
if (this.wasCaught) {
|
||||||
this.currentWidth = newWidth;
|
if (this.finaleProgression < FEAR_SETTINGS.EVENT_FINALE_DURATION) {
|
||||||
this.emit();
|
this.finaleProgression = Math.min(
|
||||||
}
|
this.finaleProgression + delta,
|
||||||
},
|
FEAR_SETTINGS.EVENT_FINALE_DURATION
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerLoop(direction: 'forward' | 'backward') {
|
this.emit();
|
||||||
this.loopCount += 1;
|
},
|
||||||
console.log(`Hallway looped ${direction}. Total loops: ${this.loopCount}`);
|
|
||||||
this.emit();
|
registerLoop(direction: 'forward' | 'backward') {
|
||||||
}
|
this.loopCount += 1;
|
||||||
};
|
|
||||||
|
this.isRustActive = this.loopCount >= FEAR_SETTINGS.EVENT_RUST_LOOP_COUNT;
|
||||||
|
this.finaleTriggered =
|
||||||
|
this.loopCount >= FEAR_SETTINGS.EVENT_FINALE_LOOP_COUNT;
|
||||||
|
|
||||||
|
this.emit();
|
||||||
|
},
|
||||||
|
|
||||||
|
registerCaught() {
|
||||||
|
this.wasCaught = true;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+20
-7
@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from 'next';
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '⛧',
|
title: 'site under construction',
|
||||||
// description: '',
|
// description: '',
|
||||||
openGraph: {
|
openGraph: {
|
||||||
// title: '⛧',
|
// title: '⛧',
|
||||||
@@ -11,15 +11,15 @@ export const metadata: Metadata = {
|
|||||||
{
|
{
|
||||||
url: 'https://neru.rip/img/ok.jpg',
|
url: 'https://neru.rip/img/ok.jpg',
|
||||||
width: 734,
|
width: 734,
|
||||||
height: 1104,
|
height: 1104
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: '#fbcfe8',
|
themeColor: '#fbcfe8'
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children
|
children
|
||||||
@@ -47,7 +47,20 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
<link rel='manifest' href='/site.webmanifest' />
|
<link rel='manifest' href='/site.webmanifest' />
|
||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
<body className={`antialiased`}>{children}</body>
|
<body className={`antialiased`}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={'construction.jpg'}
|
||||||
|
style={{ width: '100%', maxWidth: '600px', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,4 +43,4 @@
|
|||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-133
@@ -2,10 +2,21 @@
|
|||||||
|
|
||||||
import './page.css';
|
import './page.css';
|
||||||
|
|
||||||
import { Environment, OrbitControls, useProgress } from "@react-three/drei";
|
import { Environment, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
import { Canvas, useLoader } from '@react-three/fiber';
|
import { Canvas, useLoader } from '@react-three/fiber';
|
||||||
import { Bloom, BrightnessContrast, DepthOfField, EffectComposer, HueSaturation, LUT, Noise, SMAA, SSAO, Vignette } from '@react-three/postprocessing';
|
import {
|
||||||
import { useLayoutEffect, useState } from "react";
|
Bloom,
|
||||||
|
BrightnessContrast,
|
||||||
|
DepthOfField,
|
||||||
|
EffectComposer,
|
||||||
|
HueSaturation,
|
||||||
|
LUT,
|
||||||
|
Noise,
|
||||||
|
SMAA,
|
||||||
|
SSAO,
|
||||||
|
Vignette
|
||||||
|
} from '@react-three/postprocessing';
|
||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
import { folder, useControls, Leva } from 'leva';
|
import { folder, useControls, Leva } from 'leva';
|
||||||
import SealCube from './scene-components/sealcube';
|
import SealCube from './scene-components/sealcube';
|
||||||
import Terrain from './scene-components/terrain';
|
import Terrain from './scene-components/terrain';
|
||||||
@@ -13,155 +24,162 @@ import { LUTCubeLoader } from 'three/examples/jsm/Addons.js';
|
|||||||
import { AmbientSound } from './scene-components/ambient-sound';
|
import { AmbientSound } from './scene-components/ambient-sound';
|
||||||
|
|
||||||
function Loader() {
|
function Loader() {
|
||||||
const { progress, active } = useProgress();
|
const { progress, active } = useProgress();
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!active && progress === 100) {
|
if (!active && progress === 100) {
|
||||||
const timeout = setTimeout(() => setVisible(false), 500);
|
const timeout = setTimeout(() => setVisible(false), 500);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [progress, active]);
|
}, [progress, active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`loader ${!visible ? 'loader hidden' : ''}`}>
|
<div className={`loader ${!visible ? 'loader hidden' : ''}`}>
|
||||||
<picture>
|
<picture>
|
||||||
<img src='niko/img/niko.jpg' className='niko-spin' />
|
<img src='niko/img/niko.jpg' className='niko-spin' />
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Scene() {
|
function Scene() {
|
||||||
const {
|
const {
|
||||||
terrainDryColor,
|
terrainDryColor,
|
||||||
terrainLushColor,
|
terrainLushColor,
|
||||||
chunks,
|
chunks,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
resolution,
|
resolution,
|
||||||
hillScale,
|
hillScale,
|
||||||
hillHeight,
|
hillHeight,
|
||||||
detailScale,
|
detailScale,
|
||||||
detailHeight,
|
detailHeight,
|
||||||
grassDryColor,
|
grassDryColor,
|
||||||
grassLushColor,
|
grassLushColor,
|
||||||
grassCount,
|
grassCount,
|
||||||
grassSize,
|
grassSize,
|
||||||
grassLOD,
|
grassLOD,
|
||||||
grassBlades,
|
grassBlades,
|
||||||
grassSegments,
|
grassSegments,
|
||||||
grassLODStart,
|
grassLODStart,
|
||||||
grassLODExponent
|
grassLODExponent
|
||||||
} = useControls('Environment', {
|
} = useControls('Environment', {
|
||||||
Terrain: folder({
|
Terrain: folder({
|
||||||
terrainDryColor: '#232a0c',
|
terrainDryColor: '#232a0c',
|
||||||
terrainLushColor: '#142a14',
|
terrainLushColor: '#142a14',
|
||||||
chunks: { value: 16, min: 4, max: 24, step: 2 },
|
chunks: { value: 16, min: 4, max: 24, step: 2 },
|
||||||
chunkSize: { value: 10.0, min: 5.0, max: 40.0, step: 1.0 },
|
chunkSize: { value: 10.0, min: 5.0, max: 40.0, step: 1.0 },
|
||||||
resolution: { value: 8.0, min: 4.0, max: 30.0, step: 1.0 },
|
resolution: { value: 8.0, min: 4.0, max: 30.0, step: 1.0 },
|
||||||
hillScale: { value: 0.15, min: 0.01, max: 0.5, step: 0.01 },
|
hillScale: { value: 0.15, min: 0.01, max: 0.5, step: 0.01 },
|
||||||
hillHeight: { value: 4.0, min: 0.0, max: 20.0, step: 0.5 },
|
hillHeight: { value: 4.0, min: 0.0, max: 20.0, step: 0.5 },
|
||||||
detailScale: { value: 1.0, min: 0.1, max: 5.0, step: 0.1 },
|
detailScale: { value: 1.0, min: 0.1, max: 5.0, step: 0.1 },
|
||||||
detailHeight: { value: 0.3, min: 0.0, max: 2.0, step: 0.05 },
|
detailHeight: { value: 0.3, min: 0.0, max: 2.0, step: 0.05 }
|
||||||
}),
|
}),
|
||||||
Grass: folder({
|
Grass: folder({
|
||||||
grassDryColor: '#495a17',
|
grassDryColor: '#495a17',
|
||||||
grassLushColor: '#255825',
|
grassLushColor: '#255825',
|
||||||
grassCount: { value: 8000, min: 1000, max: 30000, step: 500 },
|
grassCount: { value: 8000, min: 1000, max: 30000, step: 500 },
|
||||||
grassSize: { value: 0.65, min: 0.1, max: 2.0, step: 0.05 },
|
grassSize: { value: 0.85, min: 0.1, max: 2.0, step: 0.05 },
|
||||||
grassLOD: { value: 60, min: 10, max: 200, step: 5 },
|
grassLOD: { value: 60, min: 10, max: 200, step: 5 },
|
||||||
grassBlades: { value: 3, min: 1, max: 4, step: 1 },
|
grassBlades: { value: 3, min: 1, max: 5, step: 1 },
|
||||||
grassSegments: { value: 3, min: 1, max: 5, step: 1 },
|
grassSegments: { value: 4, min: 1, max: 5, step: 1 },
|
||||||
grassLODStart: { value: 0.15, min: 0.0, max: 0.9, step: 0.05 },
|
grassLODStart: { value: 0.15, min: 0.0, max: 0.9, step: 0.05 },
|
||||||
grassLODExponent: { value: 1.8, min: 0.5, max: 3.0, step: 0.1 },
|
grassLODExponent: { value: 1.8, min: 0.5, max: 3.0, step: 0.1 }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return (<>
|
return (
|
||||||
<Environment
|
<>
|
||||||
files={'niko/hdr/sky.hdr'}
|
<Environment
|
||||||
environmentIntensity={1}
|
files={'niko/hdr/sky.hdr'}
|
||||||
background
|
environmentIntensity={0.85}
|
||||||
/>
|
background
|
||||||
|
/>
|
||||||
|
|
||||||
<fogExp2 attach='fog' args={[0x9a9a9a, 0.01]} />
|
<fogExp2 attach='fog' args={[0xa3a5ba, 0.0125]} />
|
||||||
|
|
||||||
<Terrain
|
<ambientLight intensity={0.5} />
|
||||||
chunks={chunks}
|
<directionalLight position={[15, 25, 15]} intensity={1} />
|
||||||
chunkSize={chunkSize}
|
|
||||||
resolution={resolution}
|
|
||||||
scale={1}
|
|
||||||
hillScale={hillScale}
|
|
||||||
hillHeight={hillHeight}
|
|
||||||
detailScale={detailScale}
|
|
||||||
detailHeight={detailHeight}
|
|
||||||
grassCount={grassCount}
|
|
||||||
grassSize={grassSize}
|
|
||||||
grassLOD={grassLOD}
|
|
||||||
terrainDryColor={terrainDryColor}
|
|
||||||
terrainLushColor={terrainLushColor}
|
|
||||||
grassDryColor={grassDryColor}
|
|
||||||
grassLushColor={grassLushColor}
|
|
||||||
grassBlades={grassBlades}
|
|
||||||
grassSegments={grassSegments}
|
|
||||||
grassLODStart={grassLODStart}
|
|
||||||
grassLODExponent={grassLODExponent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SealCube />
|
<Terrain
|
||||||
</>)
|
chunks={chunks}
|
||||||
|
chunkSize={chunkSize}
|
||||||
|
resolution={resolution}
|
||||||
|
scale={1}
|
||||||
|
hillScale={hillScale}
|
||||||
|
hillHeight={hillHeight}
|
||||||
|
detailScale={detailScale}
|
||||||
|
detailHeight={detailHeight}
|
||||||
|
grassCount={grassCount}
|
||||||
|
grassSize={grassSize}
|
||||||
|
grassLOD={grassLOD}
|
||||||
|
terrainDryColor={terrainDryColor}
|
||||||
|
terrainLushColor={terrainLushColor}
|
||||||
|
grassDryColor={grassDryColor}
|
||||||
|
grassLushColor={grassLushColor}
|
||||||
|
grassBlades={grassBlades}
|
||||||
|
grassSegments={grassSegments}
|
||||||
|
grassLODStart={grassLODStart}
|
||||||
|
grassLODExponent={grassLODExponent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SealCube />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LutEffect() {
|
function LutEffect() {
|
||||||
const lutTexture = useLoader(LUTCubeLoader, 'niko/lut/Landscape6.cube');
|
const lutTexture = useLoader(LUTCubeLoader, 'niko/lut/Landscape6.cube');
|
||||||
return <LUT lut={lutTexture.texture3D} />;
|
return <LUT lut={lutTexture.texture3D} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostProcessing() {
|
function PostProcessing() {
|
||||||
return (<EffectComposer>
|
return (
|
||||||
<DepthOfField target={[0, 3, 0]} focalLength={10} bokehScale={5} />
|
<EffectComposer>
|
||||||
<Vignette />
|
<DepthOfField target={[0, 3, 0]} focalLength={10} bokehScale={5} />
|
||||||
<Noise opacity={0.05} />
|
<Vignette />
|
||||||
<Bloom
|
<Noise opacity={0.05} />
|
||||||
intensity={2}
|
<Bloom
|
||||||
luminanceThreshold={0.5}
|
intensity={0.8}
|
||||||
luminanceSmoothing={0.1}
|
luminanceThreshold={0.4}
|
||||||
/>
|
luminanceSmoothing={0.5}
|
||||||
<SMAA />
|
/>
|
||||||
<HueSaturation saturation={0.3} />
|
<SMAA />
|
||||||
<BrightnessContrast brightness={0.05} contrast={-0.1} />
|
<HueSaturation saturation={0.3} />
|
||||||
<LutEffect />
|
<BrightnessContrast brightness={0.05} contrast={-0.1} />
|
||||||
</EffectComposer>)
|
<LutEffect />
|
||||||
|
</EffectComposer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Seal() {
|
export default function Seal() {
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Leva hidden={isProduction} />
|
<Leva hidden={isProduction} />
|
||||||
|
|
||||||
<Loader />
|
<Loader />
|
||||||
<Canvas
|
<Canvas
|
||||||
shadows
|
shadows
|
||||||
camera={{ position: [0, 5, 15], fov: 50, far: 100 }}
|
camera={{ position: [0, 5, 15], fov: 50, far: 100 }}
|
||||||
gl={{ antialias: true }}
|
gl={{ antialias: false, powerPreference: 'high-performance' }}
|
||||||
className='canvas'
|
className='canvas'
|
||||||
>
|
>
|
||||||
<AmbientSound url="niko/snd/wind.mp3" volume={0.4}/>
|
<AmbientSound url='niko/snd/wind.mp3' volume={0.4} />
|
||||||
<AmbientSound url="niko/snd/birds.mp3" volume={0.1}/>
|
<AmbientSound url='niko/snd/birds.mp3' volume={0.1} />
|
||||||
|
|
||||||
<Scene />
|
<Scene />
|
||||||
<PostProcessing />
|
<PostProcessing />
|
||||||
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
target={[0, 3, 0]}
|
target={[0, 3, 0]}
|
||||||
enablePan={false}
|
enablePan={false}
|
||||||
makeDefault
|
makeDefault
|
||||||
minDistance={2}
|
minDistance={2}
|
||||||
maxDistance={6}
|
maxDistance={6}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface AmbientSoundProps {
|
interface AmbientSoundProps {
|
||||||
url: string
|
url: string;
|
||||||
volume?: number
|
volume?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = new Audio(url)
|
const audio = new Audio(url);
|
||||||
audio.loop = true
|
audio.loop = true;
|
||||||
audio.volume = volume
|
audio.volume = volume;
|
||||||
audioRef.current = audio
|
audioRef.current = audio;
|
||||||
|
|
||||||
const startAudio = () => {
|
const startAudio = () => {
|
||||||
audio.play().catch((err) => {
|
audio.play().catch((err) => {
|
||||||
console.warn('Autoplay blocked. Waiting for user interaction.', err)
|
console.warn('Autoplay blocked. Waiting for user interaction.', err);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
startAudio()
|
startAudio();
|
||||||
|
|
||||||
window.addEventListener('click', startAudio, { once: true })
|
window.addEventListener('click', startAudio, { once: true });
|
||||||
window.addEventListener('keydown', startAudio, { once: true })
|
window.addEventListener('keydown', startAudio, { once: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', startAudio)
|
window.removeEventListener('click', startAudio);
|
||||||
window.removeEventListener('keydown', startAudio)
|
window.removeEventListener('keydown', startAudio);
|
||||||
audio.pause()
|
audio.pause();
|
||||||
audioRef.current = null
|
audioRef.current = null;
|
||||||
}
|
};
|
||||||
}, [url])
|
}, [url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.volume = volume
|
audioRef.current.volume = volume;
|
||||||
}
|
}
|
||||||
}, [volume])
|
}, [volume]);
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
import { useFrame, useLoader } from "@react-three/fiber";
|
import { useFrame, useLoader } from '@react-three/fiber';
|
||||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
import { useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
import { BufferAttribute, BufferGeometry, Color, DoubleSide, InstancedMesh, MeshStandardMaterial, Object3D, TextureLoader } from "three";
|
import {
|
||||||
import { getTerrainHeight, Shader } from "./helpers";
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
Color,
|
||||||
|
DoubleSide,
|
||||||
|
InstancedMesh,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
Object3D,
|
||||||
|
TextureLoader
|
||||||
|
} from 'three';
|
||||||
|
import { getTerrainHeight, Shader } from './helpers';
|
||||||
|
|
||||||
import grassVert from './shaders/grass.vert';
|
import grassVert from './shaders/grass.vert';
|
||||||
import grassFrag from './shaders/grass.frag';
|
import grassFrag from './shaders/grass.frag';
|
||||||
|
|
||||||
interface GrassProps {
|
interface GrassProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
size: number;
|
size: number;
|
||||||
count: number;
|
count: number;
|
||||||
grassSize: number;
|
grassSize: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
hillScale: number;
|
hillScale: number;
|
||||||
hillHeight: number;
|
hillHeight: number;
|
||||||
detailScale: number;
|
detailScale: number;
|
||||||
detailHeight: number;
|
detailHeight: number;
|
||||||
noise2D: (x: number, y: number) => number;
|
noise2D: (x: number, y: number) => number;
|
||||||
grassLOD: number;
|
grassLOD: number;
|
||||||
dryColor: string;
|
dryColor: string;
|
||||||
lushColor: string;
|
lushColor: string;
|
||||||
grassBlades?: number;
|
grassBlades?: number;
|
||||||
grassSegments?: number;
|
grassSegments?: number;
|
||||||
grassLODStart?: number;
|
grassLODStart?: number;
|
||||||
grassLODExponent?: number;
|
grassLODExponent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Grass({
|
export default function Grass({
|
||||||
@@ -173,24 +182,28 @@ export default function Grass({
|
|||||||
dummy.rotation.x = (Math.random() - 0.5) * 0.2;
|
dummy.rotation.x = (Math.random() - 0.5) * 0.2;
|
||||||
dummy.rotation.z = (Math.random() - 0.5) * 0.2;
|
dummy.rotation.z = (Math.random() - 0.5) * 0.2;
|
||||||
|
|
||||||
const baseScale = grassSize + Math.random() * grassSize * 0.5;
|
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
|
||||||
const heightMult = 0.5 + Math.random() * 1.0;
|
const t = (noiseVal + 1) / 2;
|
||||||
dummy.scale.set(baseScale, baseScale * heightMult, baseScale);
|
const randomInternal = (Math.random() - 0.5) * 0.2;
|
||||||
|
const finalT = Math.max(0, Math.min(1, t + randomInternal));
|
||||||
|
color.lerpColors(dryColorObj, lushColorObj, finalT);
|
||||||
|
meshRef.current.setColorAt(instanceIndex, color);
|
||||||
|
|
||||||
|
const heightNoise = noise2D(globalX * 0.08, globalZ * 0.08);
|
||||||
|
const macroHeight = (heightNoise + 1.0) * 0.5; // 0..1
|
||||||
|
const microNoise = noise2D(globalX * 0.3, globalZ * 0.3);
|
||||||
|
const microHeight = (microNoise + 1.0) * 0.25; // 0..0.5
|
||||||
|
const perBladeRandom = Math.random() * 0.4;
|
||||||
|
|
||||||
|
const grassWidth = grassSize * (0.7 + Math.random() * 0.5);
|
||||||
|
const grassHeight =
|
||||||
|
grassSize * (0.4 + macroHeight * 0.8 + microHeight + perBladeRandom);
|
||||||
|
|
||||||
|
dummy.scale.set(grassWidth, grassHeight, grassWidth);
|
||||||
|
|
||||||
dummy.updateMatrix();
|
dummy.updateMatrix();
|
||||||
meshRef.current.setMatrixAt(instanceIndex, dummy.matrix);
|
meshRef.current.setMatrixAt(instanceIndex, dummy.matrix);
|
||||||
|
|
||||||
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
|
|
||||||
|
|
||||||
const t = (noiseVal + 1) / 2;
|
|
||||||
|
|
||||||
const randomInternal = (Math.random() - 0.5) * 0.2;
|
|
||||||
const finalT = Math.max(0, Math.min(1, t + randomInternal));
|
|
||||||
|
|
||||||
color.lerpColors(dryColorObj, lushColorObj, finalT);
|
|
||||||
|
|
||||||
meshRef.current.setColorAt(instanceIndex, color);
|
|
||||||
|
|
||||||
instanceIndex++;
|
instanceIndex++;
|
||||||
}
|
}
|
||||||
meshRef.current.count = instanceIndex;
|
meshRef.current.count = instanceIndex;
|
||||||
@@ -222,62 +235,61 @@ export default function Grass({
|
|||||||
shader.uniforms.uTime = { value: 0 };
|
shader.uniforms.uTime = { value: 0 };
|
||||||
|
|
||||||
shader.vertexShader = `
|
shader.vertexShader = `
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
varying vec2 vGrassUv;
|
varying vec2 vGrassUv;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
float hash(vec2 p) {
|
|
||||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
float hash(vec2 p) {
|
||||||
}
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||||
|
}
|
||||||
float noise(vec2 p) {
|
|
||||||
vec2 i = floor(p);
|
float noise(vec2 p) {
|
||||||
vec2 f = fract(p);
|
vec2 i = floor(p);
|
||||||
f = f * f * (3.0 - 2.0 * f);
|
vec2 f = fract(p);
|
||||||
|
f = f * f * (3.0 - 2.0 * f);
|
||||||
float a = hash(i);
|
float a = hash(i);
|
||||||
float b = hash(i + vec2(1.0, 0.0));
|
float b = hash(i + vec2(1.0, 0.0));
|
||||||
float c = hash(i + vec2(0.0, 1.0));
|
float c = hash(i + vec2(0.0, 1.0));
|
||||||
float d = hash(i + vec2(1.0, 1.0));
|
float d = hash(i + vec2(1.0, 1.0));
|
||||||
|
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
}
|
||||||
}
|
|
||||||
|
float fbm(vec2 p) {
|
||||||
float fbm(vec2 p) {
|
float value = 0.0;
|
||||||
float value = 0.0;
|
float amplitude = 0.5;
|
||||||
float amplitude = 0.5;
|
float frequency = 1.0;
|
||||||
float frequency = 1.0;
|
for(int i = 0; i < 4; i++) {
|
||||||
|
value += amplitude * noise(p * frequency);
|
||||||
for(int i = 0; i < 4; i++) {
|
frequency *= 2.0;
|
||||||
value += amplitude * noise(p * frequency);
|
amplitude *= 0.5;
|
||||||
frequency *= 2.0;
|
}
|
||||||
amplitude *= 0.5;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
${shader.vertexShader}
|
||||||
}
|
`;
|
||||||
|
|
||||||
${shader.vertexShader}
|
|
||||||
`;
|
|
||||||
shader.vertexShader = shader.vertexShader.replace(
|
shader.vertexShader = shader.vertexShader.replace(
|
||||||
'#include <begin_vertex>',
|
'#include <begin_vertex>',
|
||||||
`
|
`
|
||||||
#include <begin_vertex>
|
#include <begin_vertex>
|
||||||
vGrassUv = uv;
|
${grassVert}
|
||||||
${grassVert}
|
`
|
||||||
`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
shader.fragmentShader = `
|
shader.fragmentShader = `
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
varying vec2 vGrassUv;
|
varying vec2 vGrassUv;
|
||||||
${shader.fragmentShader}
|
varying vec3 vWorldPos;
|
||||||
`;
|
${shader.fragmentShader}
|
||||||
|
`;
|
||||||
|
|
||||||
shader.fragmentShader = shader.fragmentShader.replace(
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
'#include <color_fragment>',
|
'#include <color_fragment>',
|
||||||
`
|
`
|
||||||
#include <color_fragment>
|
#include <color_fragment>
|
||||||
${grassFrag}
|
${grassFrag}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (materialRef.current) {
|
if (materialRef.current) {
|
||||||
@@ -307,4 +319,4 @@ export default function Grass({
|
|||||||
/>
|
/>
|
||||||
</instancedMesh>
|
</instancedMesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
export function getTerrainHeight(
|
export function getTerrainHeight(
|
||||||
localX: number,
|
localX: number,
|
||||||
localZ: number,
|
localZ: number,
|
||||||
worldXBase: number,
|
worldXBase: number,
|
||||||
worldZBase: number,
|
worldZBase: number,
|
||||||
scale: number,
|
scale: number,
|
||||||
hillScale: number,
|
hillScale: number,
|
||||||
hillHeight: number,
|
hillHeight: number,
|
||||||
detailScale: number,
|
detailScale: number,
|
||||||
detailHeight: number,
|
detailHeight: number,
|
||||||
noise2D: (x: number, y: number) => number
|
noise2D: (x: number, y: number) => number
|
||||||
) {
|
) {
|
||||||
const worldX = (worldXBase + localX) * 0.1;
|
const worldX = (worldXBase + localX) * 0.1;
|
||||||
const worldZ = (worldZBase + localZ) * 0.1;
|
const worldZ = (worldZBase + localZ) * 0.1;
|
||||||
|
|
||||||
const noiseHill =
|
const noiseHill =
|
||||||
noise2D(worldX * hillScale, worldZ * hillScale) * hillHeight;
|
noise2D(worldX * hillScale, worldZ * hillScale) * hillHeight;
|
||||||
const noiseDetail =
|
const noiseDetail =
|
||||||
noise2D(worldX * detailScale, worldZ * detailScale) * detailHeight;
|
noise2D(worldX * detailScale, worldZ * detailScale) * detailHeight;
|
||||||
|
|
||||||
return (noiseHill + noiseDetail) * scale;
|
return (noiseHill + noiseDetail) * scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shader {
|
export interface Shader {
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
import { useFrame, useLoader } from "@react-three/fiber";
|
import { useFrame, useLoader } from '@react-three/fiber';
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||||
import { Mesh, TextureLoader } from "three";
|
import { Mesh, TextureLoader } from 'three';
|
||||||
|
|
||||||
const SealCube = forwardRef<Mesh>((props, ref) => {
|
const SealCube = forwardRef<Mesh>((props, ref) => {
|
||||||
const texture = useLoader(TextureLoader, 'niko/img/niko.jpg');
|
const texture = useLoader(TextureLoader, 'niko/img/niko.jpg');
|
||||||
const meshRef = useRef<Mesh>(null);
|
const meshRef = useRef<Mesh>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => meshRef.current!, []);
|
useImperativeHandle(ref, () => meshRef.current!, []);
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
useFrame((state, delta) => {
|
||||||
if (meshRef.current) {
|
if (meshRef.current) {
|
||||||
meshRef.current.rotation.x += delta * 0.5;
|
meshRef.current.rotation.x += delta * 0.5;
|
||||||
meshRef.current.rotation.y += delta * 0.5;
|
meshRef.current.rotation.y += delta * 0.5;
|
||||||
}
|
meshRef.current.position.y =
|
||||||
});
|
3 + Math.sin(state.clock.getElapsedTime() * 1) * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh ref={meshRef} position={[0, 3, 0]} castShadow receiveShadow>
|
<mesh ref={meshRef} position={[0, 3, 0]} castShadow receiveShadow>
|
||||||
<boxGeometry args={[0.85, 0.85, 0.85]} />
|
<boxGeometry args={[0.85, 0.85, 0.85]} />
|
||||||
<meshBasicMaterial map={texture} depthWrite={true} />
|
<meshStandardMaterial
|
||||||
</mesh>
|
map={texture}
|
||||||
);
|
roughness={0.4}
|
||||||
|
metalness={0.1}
|
||||||
|
envMapIntensity={1.2}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
SealCube.displayName = 'SealCube';
|
SealCube.displayName = 'SealCube';
|
||||||
|
|
||||||
export default SealCube;
|
export default SealCube;
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
float ao = smoothstep(0.0, 0.7, vGrassUv.y);
|
float ao = smoothstep(0.0, 0.7, vGrassUv.y);
|
||||||
ao = mix(0.05, 1.0, ao);
|
ao = mix(0.05, 1.0, pow(ao, 1.6));
|
||||||
|
|
||||||
vec3 rootColor = diffuseColor.rgb * 0.4;
|
vec3 rootColor = diffuseColor.rgb * 0.15;
|
||||||
vec3 tipColor = diffuseColor.rgb * 1.5;
|
vec3 midColor = diffuseColor.rgb;
|
||||||
|
vec3 tipColor = diffuseColor.rgb * 1.3 + vec3(0.06, 0.08, 0.0);
|
||||||
|
|
||||||
vec3 grassColor = mix(rootColor, tipColor, vGrassUv.y);
|
float heightParam = vGrassUv.y;
|
||||||
grassColor *= ao;
|
vec3 grassColor;
|
||||||
|
if (heightParam < 0.4) {
|
||||||
|
float t = smoothstep(0.0, 0.4, heightParam);
|
||||||
|
grassColor = mix(rootColor, midColor, t);
|
||||||
|
} else {
|
||||||
|
float t = smoothstep(0.4, 1.0, heightParam);
|
||||||
|
grassColor = mix(midColor, tipColor, t);
|
||||||
|
}
|
||||||
|
|
||||||
float translucency = pow(vGrassUv.y, 2.0) * 0.5;
|
vec3 viewDir = normalize(cameraPosition - vWorldPos);
|
||||||
grassColor += diffuseColor.rgb * translucency;
|
vec3 lightDir = normalize(vec3(15.0, 25.0, 15.0));
|
||||||
|
|
||||||
diffuseColor.rgb = grassColor;
|
float VdotL = max(0.0, dot(viewDir, -lightDir));
|
||||||
|
float sss = pow(VdotL, 3.0) * smoothstep(0.2, 0.9, vGrassUv.y);
|
||||||
|
|
||||||
|
vec3 sssColor = diffuseColor.rgb * vec3(0.6, 1.0, 0.15) * 1.8;
|
||||||
|
grassColor += sssColor * sss * 2.0;
|
||||||
|
|
||||||
|
float NdotV = 1.0 - max(0.0, dot(normalize(vNormal), viewDir));
|
||||||
|
float rim = pow(NdotV, 3.0) * smoothstep(0.3, 1.0, vGrassUv.y) * 0.15;
|
||||||
|
grassColor += vec3(0.3, 0.5, 0.1) * rim;
|
||||||
|
|
||||||
|
diffuseColor.rgb = grassColor * ao;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
vGrassUv = uv;
|
||||||
|
|
||||||
vec4 worldPos = modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
vec4 worldPos = modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||||
float gx = worldPos.x;
|
float gx = worldPos.x;
|
||||||
float gz = worldPos.z;
|
float gz = worldPos.z;
|
||||||
@@ -8,7 +10,6 @@ float windSpeed = 1.5;
|
|||||||
float windTime = uTime * windSpeed;
|
float windTime = uTime * windSpeed;
|
||||||
vec2 windSamplePos = (worldPos.xz * 0.05) - (mainWindDir * windTime * 0.2);
|
vec2 windSamplePos = (worldPos.xz * 0.05) - (mainWindDir * windTime * 0.2);
|
||||||
|
|
||||||
|
|
||||||
float windBase = fbm(windSamplePos * 0.8) * 0.4 + 0.2;
|
float windBase = fbm(windSamplePos * 0.8) * 0.4 + 0.2;
|
||||||
float gustNoise = fbm(windSamplePos * 0.4);
|
float gustNoise = fbm(windSamplePos * 0.4);
|
||||||
float gust = pow(gustNoise, 3.0) * 1.8;
|
float gust = pow(gustNoise, 3.0) * 1.8;
|
||||||
@@ -20,19 +21,32 @@ float spring = sin(uTime * 2.0 + phase) * 0.06 + sin(uTime * 4.5 + phase * 1.5)
|
|||||||
float angleNoise = fbm(windSamplePos * 2.0 + uTime * 0.1) - 0.5;
|
float angleNoise = fbm(windSamplePos * 2.0 + uTime * 0.1) - 0.5;
|
||||||
vec2 windDir = normalize(mainWindDir + vec2(-mainWindDir.y, mainWindDir.x) * angleNoise * 0.4);
|
vec2 windDir = normalize(mainWindDir + vec2(-mainWindDir.y, mainWindDir.x) * angleNoise * 0.4);
|
||||||
|
|
||||||
float taperFactor = pow(uv.y, 4.0);
|
// taper (fade)
|
||||||
float taper = 1.0 - taperFactor * 0.6;
|
float taperFactor = uv.y * uv.y * uv.y;
|
||||||
|
float taper = 1.0 - taperFactor * 0.85;
|
||||||
transformed.x *= taper;
|
transformed.x *= taper;
|
||||||
transformed.z *= taper;
|
transformed.z *= taper;
|
||||||
|
|
||||||
|
// curve
|
||||||
float curveVal = fbm(vec2(gx, gz) * 0.5);
|
float curveVal = fbm(vec2(gx, gz) * 0.5);
|
||||||
float curveStrength = 2.0 + curveVal * 2.0;
|
float curveStrength = 1.5 + curveVal * 2.5;
|
||||||
float curveAmount = uv.y * uv.y * curveStrength;
|
float curveAmount = uv.y * uv.y * curveStrength;
|
||||||
vec2 curveDir = normalize(vec2(curveVal, fbm(vec2(gz, gx))) - 0.5);
|
vec2 curveDir = normalize(vec2(curveVal, fbm(vec2(gz, gx))) - 0.5);
|
||||||
transformed.x += curveAmount * curveDir.x * 0.5;
|
transformed.x += curveAmount * curveDir.x * 0.4;
|
||||||
transformed.z += curveAmount * curveDir.y * 0.5;
|
transformed.z += curveAmount * curveDir.y * 0.4;
|
||||||
|
|
||||||
|
// sway
|
||||||
float swayAmount = (totalWind + spring) * uv.y * uv.y;
|
float swayAmount = (totalWind + spring) * uv.y * uv.y;
|
||||||
transformed.x += swayAmount * windDir.x;
|
transformed.x += swayAmount * windDir.x;
|
||||||
transformed.z += swayAmount * windDir.y;
|
transformed.z += swayAmount * windDir.y;
|
||||||
transformed.y -= abs(swayAmount) * 0.2;
|
transformed.y -= abs(swayAmount) * 0.2;
|
||||||
|
|
||||||
|
// normal comp
|
||||||
|
vec2 totalBend = curveDir * curveAmount * 0.4 + windDir * swayAmount;
|
||||||
|
float bendMag = length(totalBend);
|
||||||
|
vec3 bentNormal = normalize(vec3(-totalBend.x * 0.5, 1.0, -totalBend.y * 0.5));
|
||||||
|
|
||||||
|
// normal mix
|
||||||
|
objectNormal = normalize(mix(vec3(0.0, 1.0, 0.0), bentNormal, uv.y));
|
||||||
|
|
||||||
|
vWorldPos = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from 'react';
|
||||||
import { BufferAttribute, BufferGeometry, Color, Mesh } from "three";
|
import { BufferAttribute, BufferGeometry, Color, Mesh } from 'three';
|
||||||
import { getTerrainHeight } from "./helpers";
|
import { getTerrainHeight } from './helpers';
|
||||||
import Grass from "./grass";
|
import Grass from './grass';
|
||||||
import { createNoise2D } from "simplex-noise";
|
import { createNoise2D } from 'simplex-noise';
|
||||||
|
|
||||||
interface TerrainChunkProps {
|
interface TerrainChunkProps {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -54,8 +54,10 @@ function TerrainChunk({
|
|||||||
const worldXBase = x * size;
|
const worldXBase = x * size;
|
||||||
const worldZBase = y * size;
|
const worldZBase = y * size;
|
||||||
|
|
||||||
const minX = Math.abs(worldXBase) <= halfSize ? 0 : Math.abs(worldXBase) - halfSize;
|
const minX =
|
||||||
const minZ = Math.abs(worldZBase) <= halfSize ? 0 : Math.abs(worldZBase) - halfSize;
|
Math.abs(worldXBase) <= halfSize ? 0 : Math.abs(worldXBase) - halfSize;
|
||||||
|
const minZ =
|
||||||
|
Math.abs(worldZBase) <= halfSize ? 0 : Math.abs(worldZBase) - halfSize;
|
||||||
const chunkMinDist = Math.sqrt(minX * minX + minZ * minZ);
|
const chunkMinDist = Math.sqrt(minX * minX + minZ * minZ);
|
||||||
|
|
||||||
const shouldRenderGrass = chunkMinDist < grassLOD;
|
const shouldRenderGrass = chunkMinDist < grassLOD;
|
||||||
@@ -276,4 +278,4 @@ export default function Terrain({
|
|||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-11
@@ -22,12 +22,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
cursor: url('/cur/kuromi.webp') 32 32, auto;
|
cursor:
|
||||||
|
url('/cur/kuromi.webp') 32 32,
|
||||||
|
auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-family: "Times New Roman", Times, serif;
|
font-family: 'Times New Roman', Times, serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -68,7 +70,9 @@ body {
|
|||||||
|
|
||||||
border: 1px solid var(--pink-accent);
|
border: 1px solid var(--pink-accent);
|
||||||
outline: 4px solid #fff;
|
outline: 4px solid #fff;
|
||||||
box-shadow: 0 0 0 5px var(--accent), 8px 8px 0px rgba(215, 230, 255, 0.5);
|
box-shadow:
|
||||||
|
0 0 0 5px var(--accent),
|
||||||
|
8px 8px 0px rgba(215, 230, 255, 0.5);
|
||||||
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
animation: float 5s ease-in-out infinite;
|
animation: float 5s ease-in-out infinite;
|
||||||
@@ -79,14 +83,20 @@ body {
|
|||||||
top: 10px;
|
top: 10px;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: var(--sparkle);
|
color: var(--sparkle);
|
||||||
text-shadow: 0 0 20px var(--sparkle-glow), 0 0 15px var(--sparkle-glow), 0 0 10px var(--sparkle-glow), 0 0 5px var(--sparkle-glow);
|
text-shadow:
|
||||||
|
0 0 20px var(--sparkle-glow),
|
||||||
|
0 0 15px var(--sparkle-glow),
|
||||||
|
0 0 10px var(--sparkle-glow),
|
||||||
|
0 0 5px var(--sparkle-glow);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
cursor: url('/cur/kuromi-hearts.webp') 0 0, auto !important;
|
cursor:
|
||||||
|
url('/cur/kuromi-hearts.webp') 0 0,
|
||||||
|
auto !important;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +114,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-box::before {
|
.content-box::before {
|
||||||
content: "•";
|
content: '•';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
@@ -204,15 +214,17 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.directory a::before {
|
.directory a::before {
|
||||||
content: "‹ "
|
content: '‹ ';
|
||||||
}
|
}
|
||||||
|
|
||||||
.directory a::after {
|
.directory a::after {
|
||||||
content: " ›"
|
content: ' ›';
|
||||||
}
|
}
|
||||||
|
|
||||||
.directory a:hover {
|
.directory a:hover {
|
||||||
cursor: url('/cur/kuromi-hearts.webp') 0 0, auto !important;
|
cursor:
|
||||||
|
url('/cur/kuromi-hearts.webp') 0 0,
|
||||||
|
auto !important;
|
||||||
color: var(--text-header);
|
color: var(--text-header);
|
||||||
box-shadow: 1px 1px 0px var(--pink-accent);
|
box-shadow: 1px 1px 0px var(--pink-accent);
|
||||||
background: var(--pink-accent);
|
background: var(--pink-accent);
|
||||||
@@ -234,7 +246,12 @@ h1 {
|
|||||||
|
|
||||||
border-top: 1px double var(--pink-accent);
|
border-top: 1px double var(--pink-accent);
|
||||||
border-bottom: 1px double var(--pink-accent);
|
border-bottom: 1px double var(--pink-accent);
|
||||||
background: linear-gradient(to right, transparent, rgba(255, 214, 245, 0.3), transparent);
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 214, 245, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee-track {
|
.marquee-track {
|
||||||
@@ -310,4 +327,4 @@ h1 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-47
@@ -4,10 +4,10 @@ import { useEffect, useState } from 'react';
|
|||||||
import './page.css';
|
import './page.css';
|
||||||
import { DiscordStatus } from './components/discordstatus';
|
import { DiscordStatus } from './components/discordstatus';
|
||||||
|
|
||||||
const TWITTER_LINK = "https://x.com/neruu444"
|
const TWITTER_LINK = 'https://x.com/neruu444';
|
||||||
const DISCORD_USER = "neru444"
|
const DISCORD_USER = 'neru444';
|
||||||
const DISCORD_ID = "1104474057916809226"
|
const DISCORD_ID = '1104474057916809226';
|
||||||
const STEAM_LINK = "https://steamcommunity.com/profiles/76561198440714757/"
|
const STEAM_LINK = 'https://steamcommunity.com/profiles/76561198440714757/';
|
||||||
|
|
||||||
function Content() {
|
function Content() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -16,66 +16,136 @@ function Content() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && isOpen)
|
if (e.key === 'Escape' && isOpen) setIsOpen(false);
|
||||||
setIsOpen(false);
|
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const marqueeText = "✧ ꒰ა˵• ﻌ •˵ა꒱ ✧ ฅ^•ﻌ•^ฅ ✧ ᶻ 𝗓 𐰁 /ᐠ. 。 .ᐟ\\ ✧ ฅ/ᐠ. ̫ .ᐟ\\ฅ ✧ ꒰ა≽^•⩊•^≼໒꒱ ✧ ₍˄·͈༝·͈˄₎ ✧ /ᐠ. ⩊ .ᐟ\\ノ ✧ 𓏲ּ ֶָ ࣪ /ᐠ .ᆺ. ᐟ\\ノ ✧";
|
const marqueeText =
|
||||||
|
'✧ ꒰ა˵• ﻌ •˵ა꒱ ✧ ฅ^•ﻌ•^ฅ ✧ ᶻ 𝗓 𐰁 /ᐠ. 。 .ᐟ\\ ✧ ฅ/ᐠ. ̫ .ᐟ\\ฅ ✧ ꒰ა≽^•⩊•^≼໒꒱ ✧ ₍˄·͈༝·͈˄₎ ✧ /ᐠ. ⩊ .ᐟ\\ノ ✧ 𓏲ּ ֶָ ࣪ /ᐠ .ᆺ. ᐟ\\ノ ✧';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="main-frame">
|
<div className='main-frame'>
|
||||||
<a href="/niko" className="decorative-sparkle" title="✧" style={{ left: '10px' }}>✧</a>
|
<a
|
||||||
<a href="/fear" className="decorative-sparkle" title="✧" style={{ right: '10px' }}>✧</a>
|
href='/niko'
|
||||||
|
className='decorative-sparkle'
|
||||||
|
title='✧'
|
||||||
|
style={{ left: '10px' }}
|
||||||
|
>
|
||||||
|
✧
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href='/fear'
|
||||||
|
className='decorative-sparkle'
|
||||||
|
title='✧'
|
||||||
|
style={{ right: '10px' }}
|
||||||
|
>
|
||||||
|
✧
|
||||||
|
</a>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>neru</h1>
|
<h1>neru</h1>
|
||||||
<p className="motto">˚₊‧꒰ა 𓂋 ໒꒱ ‧₊˚</p>
|
<p className='motto'>˚₊‧꒰ა 𓂋 ໒꒱ ‧₊˚</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="social-links">
|
<nav className='social-links'>
|
||||||
<a href={TWITTER_LINK} target="_blank" rel="noopener noreferrer">twitter</a> •
|
<a href={TWITTER_LINK} target='_blank' rel='noopener noreferrer'>
|
||||||
<button onClick={toggleModal}>discord</button> •
|
twitter
|
||||||
<a href={STEAM_LINK} target="_blank" rel="noopener noreferrer">steam</a>
|
</a>{' '}
|
||||||
|
•<button onClick={toggleModal}>discord</button> •
|
||||||
|
<a href={STEAM_LINK} target='_blank' rel='noopener noreferrer'>
|
||||||
|
steam
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section className="content-box">
|
<section className='content-box'>
|
||||||
<h2 className="title">✧ discord ✧</h2>
|
<h2 className='title'>✧ discord ✧</h2>
|
||||||
<DiscordStatus userId={DISCORD_ID} />
|
<DiscordStatus userId={DISCORD_ID} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="content-box">
|
<section className='content-box'>
|
||||||
<h2 className="title">✧ projects im currently working on ✧</h2>
|
<h2 className='title'>✧ projects im currently working on ✧</h2>
|
||||||
<ul className="directory">
|
<ul className='directory'>
|
||||||
<li><a href="https://git.neru.rip/neru/seallib" target="_blank" rel="noopener noreferrer">seallib</a></li>
|
<li>
|
||||||
<li><a href="https://git.neru.rip/neru/tinymitm" target="_blank" rel="noopener noreferrer">tinymitm</a></li>
|
<a
|
||||||
<li><a href="https://git.neru.rip/neru/luma" target="_blank" rel="noopener noreferrer">luma</a></li>
|
href='https://git.neru.rip/neru/seallib'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
seallib
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href='https://git.neru.rip/neru/tinymitm'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
tinymitm
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href='https://git.neru.rip/neru/luma'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
luma
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="content-box">
|
<section className='content-box'>
|
||||||
<h2 className="title">✧ sites ✧</h2>
|
<h2 className='title'>✧ sites ✧</h2>
|
||||||
<ul className="directory">
|
<ul className='directory'>
|
||||||
<li><a href="https://git.neru.rip" target="_blank" rel="noopener noreferrer">gitea</a></li>
|
<li>
|
||||||
<li><a href="https://zl.neru.rip" target="_blank" rel="noopener noreferrer">zipline</a></li>
|
<a
|
||||||
<li><a href="https://files.neru.rip" target="_blank" rel="noopener noreferrer">files</a></li>
|
href='https://git.neru.rip'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
gitea
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href='https://zl.neru.rip'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
zipline
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href='https://files.neru.rip'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
files
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="content-box">
|
<section className='content-box'>
|
||||||
<h2 className="title">✧ dumb stuff ✧</h2>
|
<h2 className='title'>✧ dumb stuff ✧</h2>
|
||||||
<ul className="directory">
|
<ul className='directory'>
|
||||||
<li><a href="discord://-/apps">break discord</a></li>
|
<li>
|
||||||
<li><a href="discord://-/channels/@me/">fix discord</a></li>
|
<a href='discord://-/apps'>break discord</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href='discord://-/channels/@me/'>fix discord</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div className="marquee">
|
<div className='marquee'>
|
||||||
<div className="marquee-track">
|
<div className='marquee-track'>
|
||||||
<span>{marqueeText}</span>
|
<span>{marqueeText}</span>
|
||||||
<span>{marqueeText}</span>
|
<span>{marqueeText}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,14 +153,20 @@ function Content() {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="modal-overlay" onClick={toggleModal}>
|
<div className='modal-overlay' onClick={toggleModal}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className='modal-content' onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">✧ discord info ✧</div>
|
<div className='modal-header'>✧ discord info ✧</div>
|
||||||
<div className="modal-body">
|
<div className='modal-body'>
|
||||||
<p><strong>User:</strong> {DISCORD_USER}</p>
|
<p>
|
||||||
<p><strong>ID:</strong> {DISCORD_ID}</p>
|
<strong>User:</strong> {DISCORD_USER}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>ID:</strong> {DISCORD_ID}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="modal-close-btn" onClick={toggleModal}>[ close ]</button>
|
<button className='modal-close-btn' onClick={toggleModal}>
|
||||||
|
[ close ]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -100,7 +176,5 @@ function Content() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <Content />;
|
||||||
<Content />
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user