Compare commits
5 Commits
363e56ff18
...
e6487de0a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6487de0a7 | ||
|
|
6566416ebd | ||
|
|
451d4ea261 | ||
|
|
141b3acf72 | ||
|
|
2613d152dd |
@ -22,6 +22,18 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 3D Canvas will be injected here by Three.js -->
|
<!-- 3D Canvas will be injected here by Three.js -->
|
||||||
|
<div id="ui-container" class="absolute top-0 left-0 w-full h-full flex justify-center items-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<button id="loadMusicButton" class="px-8 py-4 bg-[#8B4513] text-white font-bold text-2xl uppercase tracking-wider rounded-lg shadow-lg hover:bg-[#A0522D] transition duration-150 active:translate-y-px">
|
||||||
|
START THE PARTY
|
||||||
|
</button>
|
||||||
|
<input type="file" id="musicFileInput" accept=".mp3,.flac" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<div id="metadata-container" class="text-center text-white hidden">
|
||||||
|
<h1 id="song-title" class="text-4xl font-bold tracking-widest"></h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio id="audioPlayer" style="display: none;"></audio>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<!-- textures sourced from https://animalia-life.club/ -->
|
<!-- textures sourced from https://animalia-life.club/ -->
|
||||||
|
|||||||
@ -4,54 +4,33 @@ import { onResizePostprocessing } from './postprocessing.js';
|
|||||||
import { updateScreenEffect } from '../scene/magic-mirror.js'
|
import { updateScreenEffect } from '../scene/magic-mirror.js'
|
||||||
import sceneFeatureManager from '../scene/SceneFeatureManager.js';
|
import sceneFeatureManager from '../scene/SceneFeatureManager.js';
|
||||||
|
|
||||||
function updateScreenLight() {
|
|
||||||
if (state.isVideoLoaded && state.screenLight.intensity > 0) {
|
|
||||||
const pulseTarget = state.originalScreenIntensity + (Math.random() - 0.5) * state.screenIntensityPulse;
|
|
||||||
state.screenLight.intensity = THREE.MathUtils.lerp(state.screenLight.intensity, pulseTarget, 0.1);
|
|
||||||
|
|
||||||
const lightTime = Date.now() * 0.0001;
|
|
||||||
const radius = 0.01;
|
|
||||||
const centerX = 0;
|
|
||||||
const centerY = 1.5;
|
|
||||||
|
|
||||||
state.screenLight.position.x = centerX + Math.cos(lightTime) * radius;
|
|
||||||
state.screenLight.position.y = centerY + Math.sin(lightTime * 1.5) * radius * 0.5; // Slightly different freq for Y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateShaderTime() {
|
function updateShaderTime() {
|
||||||
if (state.tvScreen && state.tvScreen.material.uniforms && state.tvScreen.material.uniforms.u_time) {
|
if (state.tvScreen && state.tvScreen.material.uniforms && state.tvScreen.material.uniforms.u_time) {
|
||||||
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
|
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVideo() {
|
|
||||||
if (state.videoTexture) {
|
|
||||||
state.videoTexture.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Animation Loop ---
|
// --- Animation Loop ---
|
||||||
let lastTime = -1;
|
let lastTime = -1;
|
||||||
export function animate() {
|
export function animate() {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
|
||||||
let deltaTime = 0;
|
let deltaTime = 0;
|
||||||
if (lastTime !== -1) {
|
if (lastTime === -1) {
|
||||||
|
lastTime = state.clock.getElapsedTime();
|
||||||
|
deltaTime = 1;
|
||||||
|
} else {
|
||||||
const newTime = state.clock.getElapsedTime();
|
const newTime = state.clock.getElapsedTime();
|
||||||
deltaTime = newTime - lastTime;
|
deltaTime = newTime - lastTime;
|
||||||
lastTime = newTime;
|
lastTime = newTime;
|
||||||
} else {
|
|
||||||
lastTime = state.clock.getElapsedTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deltaTime > 0) {
|
||||||
sceneFeatureManager.update(deltaTime);
|
sceneFeatureManager.update(deltaTime);
|
||||||
state.effectsManager.update();
|
state.effectsManager.update(deltaTime);
|
||||||
updateScreenLight();
|
|
||||||
updateVideo();
|
|
||||||
updateShaderTime();
|
updateShaderTime();
|
||||||
updateScreenEffect();
|
updateScreenEffect();
|
||||||
|
}
|
||||||
|
|
||||||
// RENDER!
|
// RENDER!
|
||||||
if (state.composer) {
|
if (state.composer) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export class EffectsManager {
|
|||||||
this.effects.push(effect);
|
this.effects.push(effect);
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update(deltaTime) {
|
||||||
this.effects.forEach(effect => effect.update());
|
this.effects.forEach(effect => effect.update(deltaTime));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,15 +7,15 @@ export class DustEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_create(scene) {
|
_create(scene) {
|
||||||
const particleCount = 2000;
|
const particleCount = 3000;
|
||||||
const particlesGeometry = new THREE.BufferGeometry();
|
const particlesGeometry = new THREE.BufferGeometry();
|
||||||
const positions = [];
|
const positions = [];
|
||||||
|
|
||||||
for (let i = 0; i < particleCount; i++) {
|
for (let i = 0; i < particleCount; i++) {
|
||||||
positions.push(
|
positions.push(
|
||||||
(Math.random() - 0.5) * 15,
|
(Math.random() - 0.5) * 15,
|
||||||
Math.random() * 10,
|
Math.random() * 8,
|
||||||
(Math.random() - 0.5) * 15
|
(Math.random() - 0.5) * 45
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
particlesGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
particlesGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
@ -32,11 +32,11 @@ export class DustEffect {
|
|||||||
scene.add(this.dust);
|
scene.add(this.dust);
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update(deltaTime) {
|
||||||
if (this.dust) {
|
if (deltaTime && this.dust) {
|
||||||
const positions = this.dust.geometry.attributes.position.array;
|
const positions = this.dust.geometry.attributes.position.array;
|
||||||
for (let i = 1; i < positions.length; i += 3) {
|
for (let i = 1; i < positions.length; i += 3) {
|
||||||
positions[i] -= 0.001;
|
positions[i] -= deltaTime * 0.006;
|
||||||
if (positions[i] < -2) {
|
if (positions[i] < -2) {
|
||||||
positions[i] = 8;
|
positions[i] = 8;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,12 +19,14 @@ export class CameraManager extends SceneFeature {
|
|||||||
init() {
|
init() {
|
||||||
// The main camera from init.js is our first camera
|
// The main camera from init.js is our first camera
|
||||||
const mainCamera = state.camera;
|
const mainCamera = state.camera;
|
||||||
this.cameras.push({
|
mainCamera.fov = 20;
|
||||||
|
const mainCameraSetup = {
|
||||||
camera: mainCamera,
|
camera: mainCamera,
|
||||||
type: 'dynamic',
|
type: 'dynamic',
|
||||||
name: 'MainDynamicCamera',
|
name: 'MainDynamicCamera',
|
||||||
update: this.updateDynamicCamera, // Assign its update function
|
update: this.updateDynamicCamera, // Assign its update function
|
||||||
});
|
};
|
||||||
|
this.cameras.push(mainCameraSetup);
|
||||||
|
|
||||||
// --- Static Camera 1: Left Aisle View ---
|
// --- Static Camera 1: Left Aisle View ---
|
||||||
const staticCam1 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
const staticCam1 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||||
@ -37,8 +39,8 @@ export class CameraManager extends SceneFeature {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Static Camera 2: Right Aisle View ---
|
// --- Static Camera 2: Right Aisle View ---
|
||||||
const staticCam2 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
const staticCam2 = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||||
staticCam2.position.set(5, 4, -12);
|
staticCam2.position.set(5, 4, -6);
|
||||||
staticCam2.lookAt(0, 1.5, -18); // Look at the stage
|
staticCam2.lookAt(0, 1.5, -18); // Look at the stage
|
||||||
this.cameras.push({
|
this.cameras.push({
|
||||||
camera: staticCam2,
|
camera: staticCam2,
|
||||||
@ -47,7 +49,7 @@ export class CameraManager extends SceneFeature {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Static Camera 3: Far-Back view ---
|
// --- Static Camera 3: Far-Back view ---
|
||||||
const staticCam3 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
const staticCam3 = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||||
staticCam3.position.set(0, 3, 12);
|
staticCam3.position.set(0, 3, 12);
|
||||||
staticCam3.lookAt(0, 1.5, -20); // Look at the stage
|
staticCam3.lookAt(0, 1.5, -20); // Look at the stage
|
||||||
this.cameras.push({
|
this.cameras.push({
|
||||||
@ -57,7 +59,7 @@ export class CameraManager extends SceneFeature {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Static Camera 3: Back view ---
|
// --- Static Camera 3: Back view ---
|
||||||
const staticCam4 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
const staticCam4 = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||||
staticCam4.position.set(0, 4, 0);
|
staticCam4.position.set(0, 4, 0);
|
||||||
staticCam4.lookAt(0, 1.5, -20); // Look at the stage
|
staticCam4.lookAt(0, 1.5, -20); // Look at the stage
|
||||||
this.cameras.push({
|
this.cameras.push({
|
||||||
@ -66,6 +68,9 @@ export class CameraManager extends SceneFeature {
|
|||||||
name: 'BackCam'
|
name: 'BackCam'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// make the main camera come up more often
|
||||||
|
this.cameras.push(mainCameraSetup);
|
||||||
|
|
||||||
// --- Add Debug Helpers ---
|
// --- Add Debug Helpers ---
|
||||||
if (state.debugCamera) {
|
if (state.debugCamera) {
|
||||||
this.cameras.forEach(camData => {
|
this.cameras.forEach(camData => {
|
||||||
@ -79,7 +84,9 @@ export class CameraManager extends SceneFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This is the logic moved from animate.js
|
// This is the logic moved from animate.js
|
||||||
updateDynamicCamera() {
|
updateDynamicCamera(timeDiff) {
|
||||||
|
if (!state.partyStarted) return;
|
||||||
|
|
||||||
const globalTime = Date.now() * 0.0001;
|
const globalTime = Date.now() * 0.0001;
|
||||||
const lookAtTime = Date.now() * 0.0002;
|
const lookAtTime = Date.now() * 0.0002;
|
||||||
|
|
||||||
@ -124,12 +131,14 @@ export class CameraManager extends SceneFeature {
|
|||||||
const time = state.clock.getElapsedTime();
|
const time = state.clock.getElapsedTime();
|
||||||
|
|
||||||
// Handle camera switching
|
// Handle camera switching
|
||||||
|
if (state.partyStarted) {
|
||||||
if (time > this.lastSwitchTime + this.switchInterval) {
|
if (time > this.lastSwitchTime + this.switchInterval) {
|
||||||
const newIndex = Math.floor(Math.random() * this.cameras.length);
|
const newIndex = Math.floor(Math.random() * this.cameras.length);
|
||||||
this.switchCamera(newIndex);
|
this.switchCamera(newIndex);
|
||||||
this.lastSwitchTime = time;
|
this.lastSwitchTime = time;
|
||||||
this.switchInterval = minSwitchInterval + Math.random() * (maxSwitchInterval - minSwitchInterval);
|
this.switchInterval = minSwitchInterval + Math.random() * (maxSwitchInterval - minSwitchInterval);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the currently active camera if it has an update function
|
// Update the currently active camera if it has an update function
|
||||||
const activeCamData = this.cameras[this.activeCameraIndex];
|
const activeCamData = this.cameras[this.activeCameraIndex];
|
||||||
@ -137,6 +146,11 @@ export class CameraManager extends SceneFeature {
|
|||||||
activeCamData.update();
|
activeCamData.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
// Start the camera switching timer only when the party starts
|
||||||
|
this.lastSwitchTime = state.clock.getElapsedTime();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new CameraManager();
|
new CameraManager();
|
||||||
@ -67,13 +67,15 @@ export class Dancers extends SceneFeature {
|
|||||||
const geometry = new THREE.PlaneGeometry(dancerWidth, dancerHeight);
|
const geometry = new THREE.PlaneGeometry(dancerWidth, dancerHeight);
|
||||||
const dancerPositions = [
|
const dancerPositions = [
|
||||||
new THREE.Vector3(-4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2),
|
new THREE.Vector3(-4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2),
|
||||||
new THREE.Vector3(4.5, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 1.8),
|
new THREE.Vector3(0, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 1.8),
|
||||||
|
new THREE.Vector3(4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2.2),
|
||||||
];
|
];
|
||||||
|
|
||||||
dancerPositions.forEach((pos, index) => {
|
dancerPositions.forEach((pos, index) => {
|
||||||
const material = materials[index % materials.length];
|
const material = materials[index % materials.length];
|
||||||
const dancer = new THREE.Mesh(geometry, material);
|
const dancer = new THREE.Mesh(geometry, material);
|
||||||
dancer.position.copy(pos);
|
dancer.position.copy(pos);
|
||||||
|
dancer.visible = false; // Start invisible
|
||||||
state.scene.add(dancer);
|
state.scene.add(dancer);
|
||||||
|
|
||||||
this.dancers.push({
|
this.dancers.push({
|
||||||
@ -91,6 +93,7 @@ export class Dancers extends SceneFeature {
|
|||||||
// --- Jumping State ---
|
// --- Jumping State ---
|
||||||
isJumping: false,
|
isJumping: false,
|
||||||
jumpStartTime: 0,
|
jumpStartTime: 0,
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -99,7 +102,7 @@ export class Dancers extends SceneFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
if (this.dancers.length === 0) return;
|
if (this.dancers.length === 0 || !state.partyStarted) return;
|
||||||
|
|
||||||
const cameraPosition = new THREE.Vector3();
|
const cameraPosition = new THREE.Vector3();
|
||||||
state.camera.getWorldPosition(cameraPosition);
|
state.camera.getWorldPosition(cameraPosition);
|
||||||
@ -173,13 +176,32 @@ export class Dancers extends SceneFeature {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const musicTime = state.clock.getElapsedTime();
|
const musicTime = state.clock.getElapsedTime();
|
||||||
if (state.music && state.music.beatIntensity > 0.8 && Math.random() < 0.2 && musicTime > 10) {
|
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8 && Math.random() < 0.5 && musicTime > 10) {
|
||||||
dancerObj.isJumping = true;
|
dancerObj.isJumping = true;
|
||||||
dancerObj.jumpStartTime = time;
|
dancerObj.jumpStartTime = time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
this.dancers.forEach(dancerObj => {
|
||||||
|
dancerObj.mesh.visible = true;
|
||||||
|
// Teleport to stage
|
||||||
|
dancerObj.state = 'WAITING';
|
||||||
|
dancerObj.mesh.position.y = dancerObj.baseY;
|
||||||
|
dancerObj.waitStartTime = state.clock.getElapsedTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartyEnd() {
|
||||||
|
this.dancers.forEach(dancerObj => {
|
||||||
|
dancerObj.isJumping = false;
|
||||||
|
//dancerObj.mesh.visible = false;
|
||||||
|
dancerObj.state = 'WAITING';
|
||||||
|
dancerObj.waitStartTime = state.clock.getElapsedTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new Dancers();
|
new Dancers();
|
||||||
@ -28,9 +28,11 @@ export class LightBall extends SceneFeature {
|
|||||||
const ball = new THREE.Mesh(ballGeometry, ballMaterial);
|
const ball = new THREE.Mesh(ballGeometry, ballMaterial);
|
||||||
ball.castShadow = false;
|
ball.castShadow = false;
|
||||||
ball.receiveShadow = false;
|
ball.receiveShadow = false;
|
||||||
|
ball.visible = false; // Start invisible
|
||||||
|
|
||||||
// --- Create the Light ---
|
// --- Create the Light ---
|
||||||
const light = new THREE.PointLight(color, lightIntensity, length / 1.5);
|
const light = new THREE.PointLight(color, lightIntensity, length / 1.5);
|
||||||
|
light.visible = false; // Start invisible
|
||||||
|
|
||||||
// --- Initial Position ---
|
// --- Initial Position ---
|
||||||
ball.position.set(
|
ball.position.set(
|
||||||
@ -40,7 +42,7 @@ export class LightBall extends SceneFeature {
|
|||||||
);
|
);
|
||||||
light.position.copy(ball.position);
|
light.position.copy(ball.position);
|
||||||
|
|
||||||
//state.scene.add(ball);
|
//state.scene.add(ball); // no need to show the ball
|
||||||
state.scene.add(light);
|
state.scene.add(light);
|
||||||
|
|
||||||
this.lightBalls.push({
|
this.lightBalls.push({
|
||||||
@ -54,6 +56,8 @@ export class LightBall extends SceneFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
|
if (!state.partyStarted) return;
|
||||||
|
|
||||||
const time = state.clock.getElapsedTime();
|
const time = state.clock.getElapsedTime();
|
||||||
this.lightBalls.forEach(lb => {
|
this.lightBalls.forEach(lb => {
|
||||||
const { mesh, light, driftSpeed, offset } = lb;
|
const { mesh, light, driftSpeed, offset } = lb;
|
||||||
@ -72,6 +76,20 @@ export class LightBall extends SceneFeature {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
this.lightBalls.forEach(lb => {
|
||||||
|
//lb.mesh.visible = true; // no visible ball
|
||||||
|
lb.light.visible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartyEnd() {
|
||||||
|
this.lightBalls.forEach(lb => {
|
||||||
|
lb.mesh.visible = false;
|
||||||
|
lb.light.visible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new LightBall();
|
new LightBall();
|
||||||
@ -92,6 +92,7 @@ export class MedievalMusicians extends SceneFeature {
|
|||||||
// Randomly pick one of the created materials
|
// Randomly pick one of the created materials
|
||||||
const material = materials[Math.floor(index % materials.length)];
|
const material = materials[Math.floor(index % materials.length)];
|
||||||
const musician = new THREE.Mesh(geometry, material);
|
const musician = new THREE.Mesh(geometry, material);
|
||||||
|
musician.visible = false; // Start invisible
|
||||||
musician.position.copy(pos);
|
musician.position.copy(pos);
|
||||||
state.scene.add(musician);
|
state.scene.add(musician);
|
||||||
|
|
||||||
@ -122,7 +123,7 @@ export class MedievalMusicians extends SceneFeature {
|
|||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
// Billboard effect: make each musician face the camera
|
// Billboard effect: make each musician face the camera
|
||||||
if (this.musicians.length > 0) {
|
if (this.musicians.length > 0 && state.partyStarted) {
|
||||||
const cameraPosition = new THREE.Vector3();
|
const cameraPosition = new THREE.Vector3();
|
||||||
state.camera.getWorldPosition(cameraPosition);
|
state.camera.getWorldPosition(cameraPosition);
|
||||||
|
|
||||||
@ -244,7 +245,7 @@ export class MedievalMusicians extends SceneFeature {
|
|||||||
} else {
|
} else {
|
||||||
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
|
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
|
||||||
const musicTime = state.clock.getElapsedTime();
|
const musicTime = state.clock.getElapsedTime();
|
||||||
if (state.music && state.music.beatIntensity > 0.8 && musicTime > 15) {
|
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8 && musicTime > 15) {
|
||||||
currentJumpChance = 0.1; // High, fixed chance on the beat
|
currentJumpChance = 0.1; // High, fixed chance on the beat
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,6 +258,24 @@ export class MedievalMusicians extends SceneFeature {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
this.musicians.forEach(musicianObj => {
|
||||||
|
musicianObj.mesh.visible = true;
|
||||||
|
// Teleport to stage
|
||||||
|
musicianObj.state = 'WAITING';
|
||||||
|
musicianObj.waitStartTime = state.clock.getElapsedTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartyEnd() {
|
||||||
|
this.musicians.forEach(musicianObj => {
|
||||||
|
musicianObj.isJumping = false;
|
||||||
|
musicianObj.state = 'WAITING';
|
||||||
|
musicianObj.waitStartTime = state.clock.getElapsedTime();
|
||||||
|
//musicianObj.mesh.visible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new MedievalMusicians();
|
new MedievalMusicians();
|
||||||
131
party-cathedral/src/scene/music-player.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { state } from '../state.js';
|
||||||
|
import { SceneFeature } from './SceneFeature.js';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||||
|
|
||||||
|
export class MusicPlayer extends SceneFeature {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.audioContext = null;
|
||||||
|
this.analyser = null;
|
||||||
|
this.source = null;
|
||||||
|
this.dataArray = null;
|
||||||
|
this.loudnessHistory = [];
|
||||||
|
sceneFeatureManager.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
state.music.player = document.getElementById('audioPlayer');
|
||||||
|
state.music.loudness = 0;
|
||||||
|
state.music.isLoudEnough = false;
|
||||||
|
|
||||||
|
const loadButton = document.getElementById('loadMusicButton');
|
||||||
|
const fileInput = document.getElementById('musicFileInput');
|
||||||
|
const uiContainer = document.getElementById('ui-container');
|
||||||
|
const metadataContainer = document.getElementById('metadata-container');
|
||||||
|
const songTitleElement = document.getElementById('song-title');
|
||||||
|
|
||||||
|
loadButton.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// Setup Web Audio API if not already done
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
|
this.analyser.fftSize = 128; // Lower resolution is fine for loudness
|
||||||
|
this.source = this.audioContext.createMediaElementSource(state.music.player);
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the main button
|
||||||
|
loadButton.style.display = 'none';
|
||||||
|
|
||||||
|
// Show metadata
|
||||||
|
songTitleElement.textContent = file.name.replace(/\.[^/.]+$/, ""); // Show filename without extension
|
||||||
|
metadataContainer.classList.remove('hidden');
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
state.music.player.src = url;
|
||||||
|
|
||||||
|
// Wait 5 seconds, then start the party
|
||||||
|
setTimeout(() => {
|
||||||
|
metadataContainer.classList.add('hidden');
|
||||||
|
this.startParty();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.music.player.addEventListener('ended', () => {
|
||||||
|
this.stopParty();
|
||||||
|
uiContainer.style.display = 'flex'; // Show the button again
|
||||||
|
});
|
||||||
|
|
||||||
|
state.clock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
startParty() {
|
||||||
|
state.clock.start();
|
||||||
|
state.music.player.play();
|
||||||
|
document.getElementById('ui-container').style.display = 'none';
|
||||||
|
state.partyStarted = true;
|
||||||
|
|
||||||
|
// You could add BPM detection here in the future
|
||||||
|
// For now, we use the fixed BPM
|
||||||
|
|
||||||
|
// Trigger 'start' event for other features
|
||||||
|
this.notifyFeatures('onPartyStart');
|
||||||
|
}
|
||||||
|
|
||||||
|
stopParty() {
|
||||||
|
state.clock.stop();
|
||||||
|
state.partyStarted = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
const startButton = document.getElementById('loadMusicButton');
|
||||||
|
startButton.style.display = 'block';
|
||||||
|
startButton.textContent = "Party some more?"
|
||||||
|
}, 5000);
|
||||||
|
// Trigger 'end' event for other features
|
||||||
|
this.notifyFeatures('onPartyEnd');
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyFeatures(methodName) {
|
||||||
|
sceneFeatureManager.features.forEach(feature => {
|
||||||
|
if (typeof feature[methodName] === 'function') {
|
||||||
|
feature[methodName]();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (!state.partyStarted || !this.analyser) return;
|
||||||
|
|
||||||
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
|
// --- Calculate current loudness ---
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < this.dataArray.length; i++) {
|
||||||
|
sum += this.dataArray[i];
|
||||||
|
}
|
||||||
|
const average = sum / this.dataArray.length;
|
||||||
|
state.music.loudness = average / 255; // Normalize to 0-1 range
|
||||||
|
|
||||||
|
// --- Track loudness over the last 2 seconds ---
|
||||||
|
this.loudnessHistory.push(state.music.loudness);
|
||||||
|
if (this.loudnessHistory.length > 120) { // Assuming ~60fps, 2 seconds of history
|
||||||
|
this.loudnessHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Determine if it's loud enough to jump ---
|
||||||
|
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
|
||||||
|
const quietThreshold = 0.1; // Adjust this value based on testing
|
||||||
|
|
||||||
|
state.music.isLoudEnough = avgLoudness > quietThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new MusicPlayer();
|
||||||
@ -16,11 +16,12 @@ export class MusicVisualizer extends SceneFeature {
|
|||||||
measureDuration: (60 / 120) * 4,
|
measureDuration: (60 / 120) * 4,
|
||||||
beatIntensity: 0,
|
beatIntensity: 0,
|
||||||
measurePulse: 0,
|
measurePulse: 0,
|
||||||
|
isLoudEnough: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
if (!state.music) return;
|
if (!state.music || !state.partyStarted) return;
|
||||||
|
|
||||||
const time = state.clock.getElapsedTime();
|
const time = state.clock.getElapsedTime();
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export class PartyGuests extends SceneFeature {
|
|||||||
guestHeight / 2,
|
guestHeight / 2,
|
||||||
(Math.random() * 20) - 2 // Position them in the main hall
|
(Math.random() * 20) - 2 // Position them in the main hall
|
||||||
);
|
);
|
||||||
|
guest.visible = false; // Start invisible
|
||||||
guest.position.copy(pos);
|
guest.position.copy(pos);
|
||||||
state.scene.add(guest);
|
state.scene.add(guest);
|
||||||
|
|
||||||
@ -92,14 +93,14 @@ export class PartyGuests extends SceneFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
if (this.guests.length === 0) return;
|
if (this.guests.length === 0 || !state.partyStarted) return;
|
||||||
|
|
||||||
const cameraPosition = new THREE.Vector3();
|
const cameraPosition = new THREE.Vector3();
|
||||||
state.camera.getWorldPosition(cameraPosition);
|
state.camera.getWorldPosition(cameraPosition);
|
||||||
|
|
||||||
const time = state.clock.getElapsedTime();
|
const time = state.clock.getElapsedTime();
|
||||||
const moveSpeed = 1.0; // Move slower
|
const moveSpeed = 1.0; // Move slower
|
||||||
const movementArea = { x: 10, z: 30, y: 0, centerZ: 2 };
|
const movementArea = { x: 10, z: 20, y: 0, centerZ: -4 };
|
||||||
const jumpChance = 0.05; // Jump way more
|
const jumpChance = 0.05; // Jump way more
|
||||||
const jumpDuration = 0.5;
|
const jumpDuration = 0.5;
|
||||||
const jumpHeight = 0.1;
|
const jumpHeight = 0.1;
|
||||||
@ -154,7 +155,7 @@ export class PartyGuests extends SceneFeature {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
|
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
|
||||||
if (state.music && state.music.beatIntensity > 0.8) {
|
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8) {
|
||||||
currentJumpChance = 0.1; // High, fixed chance on the beat
|
currentJumpChance = 0.1; // High, fixed chance on the beat
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,6 +167,25 @@ export class PartyGuests extends SceneFeature {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
const stageFrontZ = -40 / 2 + 5 + 5; // In front of the stage
|
||||||
|
this.guests.forEach((guestObj, index) => {
|
||||||
|
guestObj.mesh.visible = true;
|
||||||
|
// Rush to the stage
|
||||||
|
guestObj.state = 'MOVING';
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
guestObj.targetPosition.z = stageFrontZ + (Math.random() - 0.5) * 5;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartyEnd() {
|
||||||
|
this.guests.forEach(guestObj => {
|
||||||
|
guestObj.isJumping = false;
|
||||||
|
guestObj.state = 'WAITING';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new PartyGuests();
|
new PartyGuests();
|
||||||
@ -16,6 +16,8 @@ import { MusicVisualizer } from './music-visualizer.js';
|
|||||||
import { RoseWindowLight } from './rose-window-light.js';
|
import { RoseWindowLight } from './rose-window-light.js';
|
||||||
import { RoseWindowLightshafts } from './rose-window-lightshafts.js';
|
import { RoseWindowLightshafts } from './rose-window-lightshafts.js';
|
||||||
import { StainedGlass } from './stained-glass-window.js';
|
import { StainedGlass } from './stained-glass-window.js';
|
||||||
|
import { MusicPlayer } from './music-player.js';
|
||||||
|
import { WallCurtain } from './wall-curtain.js';
|
||||||
// Scene Features ^^^
|
// Scene Features ^^^
|
||||||
|
|
||||||
// --- Scene Modeling Function ---
|
// --- Scene Modeling Function ---
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export class StageTorches extends SceneFeature {
|
|||||||
createTorch(position) {
|
createTorch(position) {
|
||||||
const torchGroup = new THREE.Group();
|
const torchGroup = new THREE.Group();
|
||||||
torchGroup.position.copy(position);
|
torchGroup.position.copy(position);
|
||||||
|
torchGroup.visible = false; // Start invisible
|
||||||
|
|
||||||
// --- Torch Holder ---
|
// --- Torch Holder ---
|
||||||
const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.6, metalness: 0.5 });
|
const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.6, metalness: 0.5 });
|
||||||
@ -57,7 +58,7 @@ export class StageTorches extends SceneFeature {
|
|||||||
torchGroup.add(pointLight);
|
torchGroup.add(pointLight);
|
||||||
|
|
||||||
// --- Particle System for Fire ---
|
// --- Particle System for Fire ---
|
||||||
const particleCount = 50;
|
const particleCount = 100;
|
||||||
const particles = new THREE.BufferGeometry();
|
const particles = new THREE.BufferGeometry();
|
||||||
const positions = [];
|
const positions = [];
|
||||||
const particleData = [];
|
const particleData = [];
|
||||||
@ -86,11 +87,30 @@ export class StageTorches extends SceneFeature {
|
|||||||
return { group: torchGroup, light: pointLight, particles: particleSystem, particleData: particleData };
|
return { group: torchGroup, light: pointLight, particles: particleSystem, particleData: particleData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetParticles(torch) {
|
||||||
|
const positions = torch.particles.geometry.attributes.position.array;
|
||||||
|
for (let i = 0; i < torch.particleData.length; i++) {
|
||||||
|
const data = torch.particleData[i];
|
||||||
|
// Reset particle
|
||||||
|
positions[i * 3] = 0;
|
||||||
|
positions[i * 3 + 1] = 1;
|
||||||
|
positions[i * 3 + 2] = 0;
|
||||||
|
data.life = Math.random() * 1.0;
|
||||||
|
data.velocity.y = Math.random() * 1.5;
|
||||||
|
}
|
||||||
|
torch.particles.geometry.attributes.position.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
update(deltaTime) {
|
update(deltaTime) {
|
||||||
|
if (!state.partyStarted) return;
|
||||||
|
|
||||||
this.torches.forEach(torch => {
|
this.torches.forEach(torch => {
|
||||||
let measurePulse = 0;
|
let measurePulse = 0;
|
||||||
if (state.music) {
|
if (state.music) {
|
||||||
measurePulse = state.music.measurePulse * 4.0; // Make flames jump higher
|
measurePulse = state.music.measurePulse * 2.0; // Make flames jump higher
|
||||||
|
}
|
||||||
|
if (state.music.isLoudEnough) {
|
||||||
|
measurePulse += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Animate Particles ---
|
// --- Animate Particles ---
|
||||||
@ -100,13 +120,13 @@ export class StageTorches extends SceneFeature {
|
|||||||
const data = torch.particleData[i];
|
const data = torch.particleData[i];
|
||||||
data.life -= deltaTime;
|
data.life -= deltaTime;
|
||||||
const yVelocity = data.velocity.y;
|
const yVelocity = data.velocity.y;
|
||||||
if (data.life <= 0) {
|
if (data.life <= 0 || positions[i * 3 + 1] < 0) {
|
||||||
// Reset particle
|
// Reset particle
|
||||||
positions[i * 3] = 0;
|
positions[i * 3] = (Math.random() - 0.5) * 0.2;
|
||||||
positions[i * 3 + 1] = 1;
|
positions[i * 3 + 1] = 1;
|
||||||
positions[i * 3 + 2] = 0;
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 0.2;
|
||||||
data.life = Math.random() * 1.0;
|
data.life = Math.random() * 1.0;
|
||||||
data.velocity.y = Math.random() * 1.5 + measurePulse;
|
data.velocity.y = Math.random() * 1.2 + measurePulse;
|
||||||
} else {
|
} else {
|
||||||
// Update position
|
// Update position
|
||||||
positions[i * 3] += data.velocity.x * deltaTime;
|
positions[i * 3] += data.velocity.x * deltaTime;
|
||||||
@ -124,12 +144,27 @@ export class StageTorches extends SceneFeature {
|
|||||||
let beatPulse = 0;
|
let beatPulse = 0;
|
||||||
if (state.music) {
|
if (state.music) {
|
||||||
beatPulse = state.music.beatIntensity * 1.5;
|
beatPulse = state.music.beatIntensity * 1.5;
|
||||||
|
if (state.music.isLoudEnough) {
|
||||||
|
beatPulse += 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
torch.light.intensity = baseIntensity + flicker + beatPulse;
|
torch.light.intensity = baseIntensity + flicker + beatPulse;
|
||||||
torch.light.position.y = lightPositionBaseY + averageY;
|
torch.light.position.y = lightPositionBaseY + averageY;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPartyStart() {
|
||||||
|
this.torches.forEach(torch => {
|
||||||
|
torch.group.visible = true;
|
||||||
|
this.resetParticles(torch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartyEnd() {
|
||||||
|
this.torches.forEach(torch => {
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new StageTorches();
|
new StageTorches();
|
||||||
90
party-cathedral/src/scene/wall-curtain.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { SceneFeature } from './SceneFeature.js';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||||
|
import curtainTextureUrl from '/textures/tapestry.png';
|
||||||
|
|
||||||
|
export class WallCurtain extends SceneFeature {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.curtains = [];
|
||||||
|
sceneFeatureManager.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// --- Curtain Properties ---
|
||||||
|
const naveWidth = 12;
|
||||||
|
const naveHeight = 7;
|
||||||
|
const stageHeight = 1.5;
|
||||||
|
const curtainWidth = naveWidth; // Span the width of the nave
|
||||||
|
const curtainHeight = naveHeight - stageHeight; // Hang from the ceiling down to the stage
|
||||||
|
const segmentsX = 50; // More segments for a smoother wave
|
||||||
|
const segmentsY = 50;
|
||||||
|
|
||||||
|
// --- Texture ---
|
||||||
|
const texture = state.loader.load(curtainTextureUrl);
|
||||||
|
texture.wrapS = THREE.RepeatWrapping;
|
||||||
|
texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.repeat.set(5, 1); // Repeat the texture 5 times horizontally
|
||||||
|
|
||||||
|
// --- Material ---
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
map: texture,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create and Place Curtains ---
|
||||||
|
const createAndPlaceCurtain = (position, rotationY) => {
|
||||||
|
const geometry = new THREE.PlaneGeometry(curtainWidth, curtainHeight, segmentsX, segmentsY);
|
||||||
|
const originalPositions = geometry.attributes.position.clone();
|
||||||
|
const curtainMesh = new THREE.Mesh(geometry, material);
|
||||||
|
curtainMesh.position.copy(position);
|
||||||
|
curtainMesh.rotation.y = rotationY;
|
||||||
|
curtainMesh.castShadow = true;
|
||||||
|
curtainMesh.receiveShadow = true;
|
||||||
|
state.scene.add(curtainMesh);
|
||||||
|
|
||||||
|
this.curtains.push({
|
||||||
|
mesh: curtainMesh,
|
||||||
|
originalPositions: originalPositions,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Place a single large curtain behind the stage
|
||||||
|
const backWallZ = -20;
|
||||||
|
const curtainY = stageHeight + curtainHeight / 2;
|
||||||
|
const curtainPosition = new THREE.Vector3(0, curtainY, backWallZ + 0.1);
|
||||||
|
|
||||||
|
createAndPlaceCurtain(curtainPosition, 0); // No rotation needed
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (!this.waving) { return; }
|
||||||
|
const time = state.clock.getElapsedTime();
|
||||||
|
const waveSpeed = 1.5;
|
||||||
|
const waveFrequency = 0.5;
|
||||||
|
const waveAmplitude = 0.2;
|
||||||
|
|
||||||
|
this.curtains.forEach(curtain => {
|
||||||
|
const positions = curtain.mesh.geometry.attributes.position;
|
||||||
|
const originalPos = curtain.originalPositions;
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
const originalX = originalPos.getX(i);
|
||||||
|
// The wave now moves horizontally across the curtain
|
||||||
|
const zOffset = Math.sin(originalX * waveFrequency + time * waveSpeed) * waveAmplitude;
|
||||||
|
positions.setZ(i, originalPos.getZ(i) + zOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark positions as needing an update
|
||||||
|
positions.needsUpdate = true;
|
||||||
|
|
||||||
|
// Recalculate normals for correct lighting on the waving surface
|
||||||
|
curtain.mesh.geometry.computeVertexNormals();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new WallCurtain();
|
||||||
@ -40,6 +40,7 @@ export function initState() {
|
|||||||
roomHeight: 3,
|
roomHeight: 3,
|
||||||
debugLight: false, // Turn on light helpers
|
debugLight: false, // Turn on light helpers
|
||||||
debugCamera: false, // Turn on camera helpers
|
debugCamera: false, // Turn on camera helpers
|
||||||
|
partyStarted: false,
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
container: document.body,
|
container: document.body,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 840 KiB |
|
Before Width: | Height: | Size: 951 KiB After Width: | Height: | Size: 823 KiB |
|
Before Width: | Height: | Size: 981 KiB After Width: | Height: | Size: 856 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 866 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 1004 KiB After Width: | Height: | Size: 878 KiB |
|
Before Width: | Height: | Size: 941 KiB After Width: | Height: | Size: 819 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 906 KiB |
BIN
party-cathedral/textures/tapestry.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |