|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Fire Escape Simulator</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/PointerLockControls.js"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/GLTFLoader.js"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/@tweakpane/[email protected]/dist/tweakpane.min.js"></script> |
|
|
<style> |
|
|
body { margin: 0; overflow: hidden; } |
|
|
#container { position: relative; } |
|
|
#ui { |
|
|
position: absolute; |
|
|
top: 10px; |
|
|
left: 10px; |
|
|
background: rgba(0,0,0,0.7); |
|
|
color: white; |
|
|
padding: 10px; |
|
|
border-radius: 5px; |
|
|
font-family: Arial, sans-serif; |
|
|
} |
|
|
#health-bar { |
|
|
width: 200px; |
|
|
height: 20px; |
|
|
background: #333; |
|
|
border-radius: 3px; |
|
|
margin-top: 5px; |
|
|
} |
|
|
#health-fill { |
|
|
height: 100%; |
|
|
width: 100%; |
|
|
background: #4CAF50; |
|
|
border-radius: 3px; |
|
|
transition: width 0.3s; |
|
|
} |
|
|
#smoke-overlay { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(50,50,50,0); |
|
|
pointer-events: none; |
|
|
transition: background 0.5s; |
|
|
} |
|
|
#start-screen { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(0,0,0,0.8); |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
color: white; |
|
|
font-family: Arial, sans-serif; |
|
|
} |
|
|
.btn { |
|
|
background: #f97316; |
|
|
color: white; |
|
|
border: none; |
|
|
padding: 10px 20px; |
|
|
margin: 10px; |
|
|
border-radius: 5px; |
|
|
cursor: pointer; |
|
|
font-size: 16px; |
|
|
transition: background 0.3s; |
|
|
} |
|
|
.btn:hover { |
|
|
background: #ea580c; |
|
|
} |
|
|
#metrics { |
|
|
position: absolute; |
|
|
bottom: 10px; |
|
|
left: 10px; |
|
|
background: rgba(0,0,0,0.7); |
|
|
color: white; |
|
|
padding: 10px; |
|
|
border-radius: 5px; |
|
|
font-family: Arial, sans-serif; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="container"> |
|
|
<div id="ui"> |
|
|
<div>Fire Escape Simulator</div> |
|
|
<div>Health: <span id="health-value">100</span>%</div> |
|
|
<div id="health-bar"><div id="health-fill"></div></div> |
|
|
<div>Time: <span id="time-value">0</span>s</div> |
|
|
<div>Distance to Exit: <span id="distance-value">0</span>m</div> |
|
|
</div> |
|
|
<div id="smoke-overlay"></div> |
|
|
<div id="metrics"> |
|
|
<div>Metrics:</div> |
|
|
<div>Path Efficiency: <span id="efficiency-value">0</span>%</div> |
|
|
<div>O2 Used: <span id="oxygen-value">0</span>%</div> |
|
|
<div>Hazards Encountered: <span id="hazards-value">0</span></div> |
|
|
</div> |
|
|
<div id="start-screen"> |
|
|
<h1 class="text-4xl font-bold mb-8">Fire Escape Simulator</h1> |
|
|
<p class="mb-8 text-center max-w-lg">A scientific instrument for studying evacuation behavior in emergency situations.<br>Test different scenarios and measure performance metrics.</p> |
|
|
<div class="flex flex-wrap justify-center"> |
|
|
<button class="btn" onclick="startSimulation('fire')">Fire Scenario</button> |
|
|
<button class="btn" onclick="startSimulation('smoke')">Smoke Scenario</button> |
|
|
<button class="btn" onclick="startSimulation('equipment')">Equipment Training</button> |
|
|
<button class="btn" onclick="startSimulation('full')">Full Simulation</button> |
|
|
</div> |
|
|
<div class="mt-8 text-sm opacity-70"> |
|
|
<p>Controls: WASD to move, SPACE to jump, MOUSE to look</p> |
|
|
<p>ESC to pause/unpause</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
|
|
|
let scene, camera, renderer, controls; |
|
|
let maze = []; |
|
|
let player = { x: 0, z: 0, health: 100 }; |
|
|
let exitPosition = { x: 0, z: 0 }; |
|
|
let simulationTime = 0; |
|
|
let timerInterval; |
|
|
let scenarioType = ''; |
|
|
let hazardsEncountered = 0; |
|
|
let oxygenUsed = 0; |
|
|
let pathHistory = []; |
|
|
let optimalPathLength = 0; |
|
|
|
|
|
|
|
|
function init() { |
|
|
|
|
|
scene = new THREE.Scene(); |
|
|
scene.background = new THREE.Color(0x333333); |
|
|
|
|
|
|
|
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); |
|
|
camera.position.y = 1.6; |
|
|
|
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
|
document.getElementById('container').appendChild(renderer.domElement); |
|
|
|
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0x404040); |
|
|
scene.add(ambientLight); |
|
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); |
|
|
directionalLight.position.set(0, 1, 0); |
|
|
scene.add(directionalLight); |
|
|
|
|
|
|
|
|
scene.fog = new THREE.FogExp2(0x999999, 0.01); |
|
|
|
|
|
|
|
|
window.addEventListener('resize', onWindowResize, false); |
|
|
|
|
|
|
|
|
document.addEventListener('click', () => { |
|
|
if (scenarioType) { |
|
|
document.body.requestPointerLock = document.body.requestPointerLock || |
|
|
document.body.mozRequestPointerLock || |
|
|
document.body.webkitRequestPointerLock; |
|
|
document.body.requestPointerLock(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.addEventListener('pointerlockchange', lockChangeAlert, false); |
|
|
document.addEventListener('mozpointerlockchange', lockChangeAlert, false); |
|
|
document.addEventListener('webkitpointerlockchange', lockChangeAlert, false); |
|
|
} |
|
|
|
|
|
function lockChangeAlert() { |
|
|
if (document.pointerLockElement === document.body || |
|
|
document.mozPointerLockElement === document.body || |
|
|
document.webkitPointerLockElement === document.body) { |
|
|
|
|
|
document.addEventListener('mousemove', updatePosition, false); |
|
|
} else { |
|
|
|
|
|
document.removeEventListener('mousemove', updatePosition, false); |
|
|
} |
|
|
} |
|
|
|
|
|
function updatePosition(e) { |
|
|
if (controls) { |
|
|
controls.lookAround(e.movementX, e.movementY); |
|
|
} |
|
|
} |
|
|
|
|
|
function onWindowResize() { |
|
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
|
camera.updateProjectionMatrix(); |
|
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
|
} |
|
|
|
|
|
|
|
|
function generateMaze(size) { |
|
|
maze = []; |
|
|
for (let i = 0; i < size; i++) { |
|
|
maze[i] = []; |
|
|
for (let j = 0; j < size; j++) { |
|
|
|
|
|
maze[i][j] = Math.random() < 0.3 ? 1 : 0; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
maze[0][0] = 0; |
|
|
maze[size-1][size-1] = 0; |
|
|
|
|
|
|
|
|
player.x = 0; |
|
|
player.z = 0; |
|
|
exitPosition.x = size - 1; |
|
|
exitPosition.z = size - 1; |
|
|
|
|
|
|
|
|
optimalPathLength = (size - 1) * 2; |
|
|
|
|
|
|
|
|
addWallsToScene(); |
|
|
|
|
|
|
|
|
addExitMarker(); |
|
|
|
|
|
|
|
|
addScenarioHazards(); |
|
|
} |
|
|
|
|
|
function addWallsToScene() { |
|
|
|
|
|
scene.children = scene.children.filter(obj => obj.userData.type !== 'wall'); |
|
|
|
|
|
const wallGeometry = new THREE.BoxGeometry(1, 2, 1); |
|
|
const wallMaterial = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); |
|
|
|
|
|
for (let i = 0; i < maze.length; i++) { |
|
|
for (let j = 0; j < maze[i].length; j++) { |
|
|
if (maze[i][j] === 1) { |
|
|
const wall = new THREE.Mesh(wallGeometry, wallMaterial); |
|
|
wall.position.set(i, 0, j); |
|
|
wall.userData.type = 'wall'; |
|
|
scene.add(wall); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const floorGeometry = new THREE.PlaneGeometry(maze.length, maze[0].length); |
|
|
const floorMaterial = new THREE.MeshPhongMaterial({ color: 0x555555, side: THREE.DoubleSide }); |
|
|
const floor = new THREE.Mesh(floorGeometry, floorMaterial); |
|
|
floor.rotation.x = -Math.PI / 2; |
|
|
floor.position.set(maze.length / 2 - 0.5, -0.5, maze[0].length / 2 - 0.5); |
|
|
scene.add(floor); |
|
|
} |
|
|
|
|
|
function addExitMarker() { |
|
|
|
|
|
scene.children = scene.children.filter(obj => obj.userData.type !== 'exit'); |
|
|
|
|
|
const exitGeometry = new THREE.BoxGeometry(0.5, 0.1, 0.5); |
|
|
const exitMaterial = new THREE.MeshPhongMaterial({ color: 0x00FF00 }); |
|
|
const exitMarker = new THREE.Mesh(exitGeometry, exitMaterial); |
|
|
exitMarker.position.set(exitPosition.x, 0.1, exitPosition.z); |
|
|
exitMarker.userData.type = 'exit'; |
|
|
scene.add(exitMarker); |
|
|
} |
|
|
|
|
|
function addScenarioHazards() { |
|
|
|
|
|
scene.children = scene.children.filter(obj => obj.userData.type !== 'hazard'); |
|
|
|
|
|
if (scenarioType === 'fire' || scenarioType === 'full') { |
|
|
|
|
|
const fireGeometry = new THREE.BoxGeometry(0.8, 0.8, 0.8); |
|
|
const fireMaterial = new THREE.MeshPhongMaterial({ color: 0xFF4500, transparent: true, opacity: 0.8 }); |
|
|
|
|
|
for (let i = 0; i < 5; i++) { |
|
|
const x = Math.floor(Math.random() * (maze.length - 2)) + 1; |
|
|
const z = Math.floor(Math.random() * (maze[0].length - 2)) + 1; |
|
|
|
|
|
if (maze[x][z] === 0 && !(x === 0 && z === 0) && !(x === exitPosition.x && z === exitPosition.z)) { |
|
|
const fire = new THREE.Mesh(fireGeometry, fireMaterial); |
|
|
fire.position.set(x, 0.5, z); |
|
|
fire.userData.type = 'hazard'; |
|
|
fire.userData.damage = 5; |
|
|
scene.add(fire); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (scenarioType === 'smoke' || scenarioType === 'full') { |
|
|
|
|
|
} |
|
|
|
|
|
if (scenarioType === 'equipment' || scenarioType === 'full') { |
|
|
|
|
|
const extGeometry = new THREE.BoxGeometry(0.3, 0.6, 0.3); |
|
|
const extMaterial = new THREE.MeshPhongMaterial({ color: 0xFF0000 }); |
|
|
|
|
|
for (let i = 0; i < 3; i++) { |
|
|
const x = Math.floor(Math.random() * (maze.length - 2)) + 1; |
|
|
const z = Math.floor(Math.random() * (maze[0].length - 2)) + 1; |
|
|
|
|
|
if (maze[x][z] === 0 && !(x === 0 && z === 0) && !(x === exitPosition.x && z === exitPosition.z)) { |
|
|
const extinguisher = new THREE.Mesh(extGeometry, extMaterial); |
|
|
extinguisher.position.set(x, 0.3, z); |
|
|
extinguisher.userData.type = 'equipment'; |
|
|
scene.add(extinguisher); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function startSimulation(type) { |
|
|
scenarioType = type; |
|
|
document.getElementById('start-screen').style.display = 'none'; |
|
|
|
|
|
|
|
|
generateMaze(10); |
|
|
|
|
|
|
|
|
player.health = 100; |
|
|
hazardsEncountered = 0; |
|
|
oxygenUsed = 0; |
|
|
simulationTime = 0; |
|
|
pathHistory = [{x: player.x, z: player.z}]; |
|
|
|
|
|
|
|
|
updateUI(); |
|
|
|
|
|
|
|
|
controls = new THREE.PointerLockControls(camera, document.body); |
|
|
|
|
|
|
|
|
timerInterval = setInterval(() => { |
|
|
simulationTime++; |
|
|
updateUI(); |
|
|
|
|
|
|
|
|
if (scenarioType === 'smoke' || scenarioType === 'full') { |
|
|
oxygenUsed += 0.5; |
|
|
if (oxygenUsed >= 100) { |
|
|
player.health -= 1; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (Math.abs(player.x - exitPosition.x) < 0.5 && Math.abs(player.z - exitPosition.z) < 0.5) { |
|
|
endSimulation(true); |
|
|
} |
|
|
|
|
|
|
|
|
if (player.health <= 0) { |
|
|
endSimulation(false); |
|
|
} |
|
|
}, 1000); |
|
|
|
|
|
|
|
|
animate(); |
|
|
} |
|
|
|
|
|
function endSimulation(success) { |
|
|
clearInterval(timerInterval); |
|
|
|
|
|
|
|
|
if (controls) { |
|
|
controls.dispose(); |
|
|
controls = null; |
|
|
} |
|
|
|
|
|
|
|
|
const startScreen = document.getElementById('start-screen'); |
|
|
startScreen.style.display = 'flex'; |
|
|
|
|
|
const efficiency = Math.round((optimalPathLength / pathHistory.length) * 100); |
|
|
|
|
|
startScreen.innerHTML = ` |
|
|
<h1 class="text-4xl font-bold mb-8">${success ? 'ESCAPE SUCCESSFUL!' : 'ESCAPE FAILED'}</h1> |
|
|
<div class="bg-gray-800 p-6 rounded-lg mb-8"> |
|
|
<h2 class="text-2xl font-semibold mb-4">Simulation Metrics</h2> |
|
|
<div class="grid grid-cols-2 gap-4"> |
|
|
<div>Scenario:</div><div>${scenarioType}</div> |
|
|
<div>Time:</div><div>${simulationTime}s</div> |
|
|
<div>Health Remaining:</div><div>${player.health}%</div> |
|
|
<div>Path Efficiency:</div><div>${efficiency}%</div> |
|
|
<div>O2 Used:</div><div>${oxygenUsed}%</div> |
|
|
<div>Hazards Encountered:</div><div>${hazardsEncountered}</div> |
|
|
</div> |
|
|
</div> |
|
|
<button class="btn" onclick="location.reload()">Run New Simulation</button> |
|
|
`; |
|
|
} |
|
|
|
|
|
function updateUI() { |
|
|
document.getElementById('health-value').textContent = player.health; |
|
|
document.getElementById('health-fill').style.width = `${player.health}%`; |
|
|
document.getElementById('time-value').textContent = simulationTime; |
|
|
|
|
|
|
|
|
const distance = Math.sqrt( |
|
|
Math.pow(player.x - exitPosition.x, 2) + |
|
|
Math.pow(player.z - exitPosition.z, 2) |
|
|
); |
|
|
document.getElementById('distance-value').textContent = distance.toFixed(1); |
|
|
|
|
|
|
|
|
const efficiency = Math.round((optimalPathLength / pathHistory.length) * 100); |
|
|
document.getElementById('efficiency-value').textContent = efficiency; |
|
|
document.getElementById('oxygen-value').textContent = oxygenUsed; |
|
|
document.getElementById('hazards-value').textContent = hazardsEncountered; |
|
|
|
|
|
|
|
|
if (scenarioType === 'smoke' || scenarioType === 'full') { |
|
|
const smokeIntensity = Math.min(oxygenUsed / 100, 0.7); |
|
|
document.getElementById('smoke-overlay').style.background = `rgba(50,50,50,${smokeIntensity})`; |
|
|
} |
|
|
} |
|
|
|
|
|
function animate() { |
|
|
requestAnimationFrame(animate); |
|
|
|
|
|
if (controls && controls.isLocked) { |
|
|
|
|
|
const moveSpeed = 0.1; |
|
|
const newPosition = { |
|
|
x: camera.position.x, |
|
|
z: camera.position.z |
|
|
}; |
|
|
|
|
|
|
|
|
if (keyState['w']) { |
|
|
newPosition.x -= Math.sin(camera.rotation.y) * moveSpeed; |
|
|
newPosition.z -= Math.cos(camera.rotation.y) * moveSpeed; |
|
|
} |
|
|
if (keyState['s']) { |
|
|
newPosition.x += Math.sin(camera.rotation.y) * moveSpeed; |
|
|
newPosition.z += Math.cos(camera.rotation.y) * moveSpeed; |
|
|
} |
|
|
if (keyState['a']) { |
|
|
newPosition.x -= Math.cos(camera.rotation.y) * moveSpeed; |
|
|
newPosition.z += Math.sin(camera.rotation.y) * moveSpeed; |
|
|
} |
|
|
if (keyState['d']) { |
|
|
newPosition.x += Math.cos(camera.rotation.y) * moveSpeed; |
|
|
newPosition.z -= Math.sin(camera.rotation.y) * moveSpeed; |
|
|
} |
|
|
|
|
|
|
|
|
const gridX = Math.round(newPosition.x); |
|
|
const gridZ = Math.round(newPosition.z); |
|
|
|
|
|
if (gridX >= 0 && gridX < maze.length && gridZ >= 0 && gridZ < maze[0].length) { |
|
|
if (maze[gridX][gridZ] === 0) { |
|
|
camera.position.x = newPosition.x; |
|
|
camera.position.z = newPosition.z; |
|
|
player.x = camera.position.x; |
|
|
player.z = camera.position.z; |
|
|
|
|
|
|
|
|
const lastPos = pathHistory[pathHistory.length - 1]; |
|
|
if (Math.abs(lastPos.x - player.x) > 0.3 || Math.abs(lastPos.z - player.z) > 0.3) { |
|
|
pathHistory.push({x: player.x, z: player.z}); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
scene.children.forEach(obj => { |
|
|
if (obj.userData.type === 'hazard') { |
|
|
const distance = Math.sqrt( |
|
|
Math.pow(camera.position.x - obj.position.x, 2) + |
|
|
Math.pow(camera.position.z - obj.position.z, 2) |
|
|
); |
|
|
|
|
|
if (distance < 1) { |
|
|
player.health -= obj.userData.damage * 0.1; |
|
|
hazardsEncountered += 0.1; |
|
|
updateUI(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
renderer.render(scene, camera); |
|
|
} |
|
|
|
|
|
|
|
|
const keyState = {}; |
|
|
document.addEventListener('keydown', (e) => { |
|
|
keyState[e.key.toLowerCase()] = true; |
|
|
|
|
|
|
|
|
if (e.key === ' ' && controls && controls.isLocked) { |
|
|
camera.position.y = 1.6; |
|
|
} |
|
|
|
|
|
|
|
|
if (e.key === 'Escape') { |
|
|
document.exitPointerLock = document.exitPointerLock || |
|
|
document.mozExitPointerLock || |
|
|
document.webkitExitPointerLock; |
|
|
document.exitPointerLock(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.addEventListener('keyup', (e) => { |
|
|
keyState[e.key.toLowerCase()] = false; |
|
|
}); |
|
|
|
|
|
|
|
|
init(); |
|
|
</script> |
|
|
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=3esign/bim-fire-escape-simulator" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
|
|
</html> |