hytopia
Version:
The HYTOPIA SDK makes it easy for developers to create massively multiplayer games using JavaScript or TypeScript.
635 lines (553 loc) • 16.2 kB
HTML
<!-- Fonts-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Creepster&display=swap" rel="stylesheet">
<!-- UI-->
<div class="boss-health-container">
<div class="boss-name"></div>
<div class="boss-health-bar">
<div class="boss-health-fill"></div>
</div>
</div>
<div class="crosshair"></div>
<div class="vignette"></div>
<div class="wave-announcement">Wave <span class="wave-number"></span></div>
<div class="game-info">
<div class="timer">Time: 00:00</div>
<div class="wave">Wave: 1</div>
</div>
<div class="hud">
<div class="weapon-info">
<div class="weapon-text">
<div class="weapon-name">Pistol</div>
<div class="weapon-ammo">Clip: 7/7</div>
</div>
<img src="" alt="Current Weapon" class="weapon-image">
</div>
<div class="info-container">
<div class="money-display">
<span class="money-symbol">$</span>
<span class="money-amount">0</span>
</div>
<div class="health-bar">
<div class="health-bar-fill"></div>
<img src="{{CDN_ASSETS_URL}}/icons/heart.png" alt="Health Icon" class="health-icon">
<div class="health-text">100/100</div>
</div>
</div>
</div>
<template id="downed-player-template">
<div class="downed-player">
<div class="revive-text">Downed Player!</div>
<div class="revive-prompt">Hold "E" to revive, stay close!</div>
<div class="revive-progress">
<div class="revive-progress-fill"></div>
</div>
<div class="label-caret"></div>
</div>
</template>
<template id="purchase-label-template">
<div class="purchase-label">
<div class="label-name"></div>
<div class="label-cost"></div>
<div class="label-prompt">Press "E" to purchase</div>
<div class="label-caret"></div>
</div>
</template>
<template id="weapon-roulette-template">
<div class="weapon-roulette">
<img class="roulette-image" src="{{CDN_ASSETS_URL}}/icons/pistol.png" alt="Weapon">
<div class="roulette-name"></div>
<div class="roulette-prompt">Press "E" to equip</div>
</div>
</template>
<!-- UI Scripts-->
<script>
const CDN_ASSETS_URL = '{{CDN_ASSETS_URL}}';
let timerInterval;
hytopia.registerSceneUITemplate('downed-player', (id, onState) => {
const template = document.getElementById('downed-player-template');
const clone = template.content.cloneNode(true);
const progressFill = clone.querySelector('.revive-progress-fill');
onState(state => {
progressFill.style.width = `${state.progress}%`;
});
return clone;
});
// register purchase label template
hytopia.registerSceneUITemplate('purchase-label', (id, onState) => {
const template = document.getElementById('purchase-label-template');
const clone = template.content.cloneNode(true);
const labelName = clone.querySelector('.label-name');
const labelCost = clone.querySelector('.label-cost');
onState(state => {
labelName.textContent = state.name;
labelCost.textContent = `$${state.cost}`;
});
return clone;
});
// register weapon roulette template
hytopia.registerSceneUITemplate('weapon-roulette', (id, onState) => {
const template = document.getElementById('weapon-roulette-template');
const clone = template.content.cloneNode(true);
const rouletteImage = clone.querySelector('.roulette-image');
const rouletteName = clone.querySelector('.roulette-name');
const roulettePrompt = clone.querySelector('.roulette-prompt');
let rouletteInterval;
// Hide prompt initially
roulettePrompt.style.display = 'none';
onState(state => {
const { selectedWeaponId, possibleWeapons } = state;
let currentIndex = 0;
// Clear any existing interval
if (rouletteInterval) {
clearInterval(rouletteInterval);
}
// Start rapid weapon cycling
rouletteInterval = setInterval(() => {
const weapon = possibleWeapons[currentIndex];
rouletteImage.src = `${CDN_ASSETS_URL}/${weapon.iconUri}`;
console.log(rouletteImage.src);
rouletteName.textContent = weapon.name;
currentIndex = (currentIndex + 1) % possibleWeapons.length;
}, 100);
// After 2 seconds, stop on selected weapon and show prompt
setTimeout(() => {
clearInterval(rouletteInterval);
const selectedWeapon = possibleWeapons.find(w => w.id === selectedWeaponId);
if (selectedWeapon) {
rouletteImage.src = `${CDN_ASSETS_URL}/${selectedWeapon.iconUri}`;
rouletteName.textContent = selectedWeapon.name;
roulettePrompt.style.display = 'block';
}
}, 3000);
});
return clone;
});
// handle game data ui updates
hytopia.onData(data => {
const { type } = data;
if (!type) {
return console.warn('No type received for data', data);
}
if (type === 'start') {
document.querySelector('.game-info').style.display = 'block';
document.querySelector('.game-info .timer').textContent = 'Time: 00:00';
document.querySelector('.game-info .wave').textContent = 'Wave: 1';
// Clear previous timer
if (timerInterval) {
clearInterval(timerInterval);
}
// Start game timer
const startTime = Date.now();
timerInterval = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsedSeconds / 60).toString().padStart(2, '0');
const seconds = (elapsedSeconds % 60).toString().padStart(2, '0');
document.querySelector('.game-info .timer').textContent = `Time: ${minutes}:${seconds}`;
}, 1000);
}
if (type === 'wave') {
const { wave } = data;
document.querySelector('.game-info .wave').textContent = `Wave: ${wave}`;
showWaveAnnouncement(wave);
}
if (type === 'weapon') {
const { name, iconImageUri } = data;
document.querySelector('.weapon-name').textContent = name;
document.querySelector('.weapon-image').src = `${CDN_ASSETS_URL}/${iconImageUri}`;
}
if (type === 'ammo') {
const { ammo, maxAmmo } = data;
document.querySelector('.weapon-ammo').textContent = `CLIP: ${ammo}/${maxAmmo}`;
}
if (type === 'health') {
const { health, maxHealth } = data;
const percentage = (health / maxHealth) * 100;
document.querySelector('.health-bar-fill').style.width = `${percentage}%`;
document.querySelector('.health-text').textContent = `${health}/${maxHealth}`;
// Update vignette intensity based on health percentage
const vignette = document.querySelector('.vignette');
const vignetteOpacity = Math.max(0, (100 - percentage) / 100);
vignette.style.opacity = vignetteOpacity;
}
if (type === 'reload') {
document.querySelector('.weapon-ammo').textContent = 'RELOADING...';
}
if (type === 'money') {
const { money } = data;
const moneyAmount = Math.floor(money);
const moneyDisplay = document.querySelector('.money-display');
const moneyAmountEl = document.querySelector('.money-amount');
moneyAmountEl.textContent = moneyAmount;
// Adjust width based on number of digits (min 2 digits, max 8 digits)
const digits = Math.max(2, Math.min(8, moneyAmount.toString().length));
moneyDisplay.style.width = `${10 + (digits * 10)}px`;
}
if (type === 'boss') {
const { name, healthPercent, show } = data;
const bossContainer = document.querySelector('.boss-health-container');
const bossName = document.querySelector('.boss-name');
const bossHealthFill = document.querySelector('.boss-health-fill');
console.log(data);
if (show === true) {
bossContainer.style.display = 'block';
} else if (show === false) {
bossContainer.style.display = 'none';
bossHealthFill.style.width = '100%';
}
if (name) {
bossName.textContent = name;
}
if (healthPercent) {
bossHealthFill.style.width = `${healthPercent}%`;
}
}
});
function showWaveAnnouncement(waveNumber) {
const announcement = document.querySelector('.wave-announcement');
const numberSpan = announcement.querySelector('.wave-number');
numberSpan.textContent = waveNumber;
announcement.classList.add('show');
setTimeout(() => {
announcement.classList.remove('show');
}, 3000);
}
</script>
<!-- UI Styles -->
<style>
.boss-health-container {
display: none;
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: 40%;
text-align: center;
}
.boss-name {
color: #ffffff;
font-family: 'Arial', sans-serif;
font-size: 24px;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
margin-bottom: 5px;
text-transform: uppercase;
}
.boss-health-bar {
width: 100%;
height: 10px;
background: rgba(0, 0, 0, 0.5);
border-radius: 3px;
overflow: hidden;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3);
}
.boss-health-fill {
width: 100%;
height: 100%;
background: linear-gradient(to right, #800000, #ff0000);
transition: width 0.3s ease;
}
.game-info {
display: none;
position: fixed;
top: 20px;
right: 20px;
}
.game-info div {
font-family: 'Arial', sans-serif;
font-size: 1em;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
color: #ffffff;
text-align: right;
margin-bottom: 5px;
}
.hud {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
z-index: 99;
gap: 10px;
font-family: 'Arial', sans-serif;
text-transform: uppercase;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.vignette {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: radial-gradient(circle, transparent 30%, rgba(255, 0, 0, 0.6));
opacity: 0;
transition: opacity 0.3s ease;
z-index: 9;
}
.info-container {
display: flex;
align-items: center;
gap: 10px;
}
.money-display {
height: 20px;
background: rgba(0, 0, 0, 0.5);
border-radius: 3px;
padding: 0 10px;
display: flex;
align-items: center;
font-weight: bold;
font-size: 0.9em;
width: 30px; /* Initial width for 2 digits */
transition: width 0.3s ease;
white-space: nowrap;
}
.money-symbol {
color: #44ff44;
margin-right: 4px;
}
.health-bar {
width: 200px;
height: 20px;
background: rgba(0, 0, 0, 0.5);
border-radius: 3px;
overflow: hidden;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3);
position: relative;
}
.health-bar-fill {
width: 100%;
height: 100%;
background: linear-gradient(to right, #ff0000, #ff3333);
transition: width 0.3s ease;
}
.health-icon {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
height: 16px;
width: 16px;
z-index: 1;
}
.health-text {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 0.8em;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
z-index: 1;
}
.weapon-info {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.weapon-text {
text-align: right;
}
.weapon-name {
font-size: 1em;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.weapon-ammo {
font-size: 0.8em;
opacity: 0.9;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.5);
}
.weapon-image {
background-color: rgba(0, 0, 0, 0.5);
border-radius: 3px;
width: 50px;
height: 50px;
object-fit: contain;
}
.crosshair {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
pointer-events: none;
opacity: 0.7;
}
.crosshair::before,
.crosshair::after {
content: '';
position: absolute;
background-color: rgba(255, 255, 255, 0.8);
}
.crosshair::before {
width: 2px;
height: 100%;
left: 50%;
transform: translateX(-50%);
}
.crosshair::after {
width: 100%;
height: 2px;
top: 50%;
transform: translateY(-50%);
}
.purchase-label {
background-color: rgba(0, 0, 0, 0.9);
padding: 12px 20px;
border-radius: 4px;
text-align: center;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
font-family: 'Arial', sans-serif;
text-transform: uppercase;
position: relative;
width: 220px;
margin: 0 auto;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
.label-name {
font-size: 20px;
margin-bottom: 6px;
font-weight: bold;
}
.label-cost {
font-size: 16px;
margin-bottom: 6px;
color: #44ff44;
}
.label-prompt {
font-size: 14px;
opacity: 0.8;
}
.label-caret {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid rgba(0, 0, 0, 0.7);
}
.weapon-roulette {
background-color: rgba(0, 0, 0, 0.9);
padding: 20px;
border-radius: 4px;
text-align: center;
position: relative;
margin: 0 auto;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
color: #ffffff;
font-family: 'Arial', sans-serif;
text-transform: uppercase;
}
.roulette-image {
width: 100px;
height: 100px;
object-fit: contain;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 3px;
padding: 10px;
margin-bottom: 10px;
}
.roulette-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.roulette-prompt {
font-size: 12px;
opacity: 0.8;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.downed-player {
background-color: rgba(0, 0, 0, 0.9);
padding: 12px 20px;
border-radius: 4px;
text-align: center;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
font-family: 'Arial', sans-serif;
text-transform: uppercase;
width: 220px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
position: relative;
}
.revive-text {
font-size: 16px;
margin-bottom: 8px;
font-weight: bold;
color: #ff4444;
}
.revive-prompt {
font-size: 14px;
opacity: 0.8;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
margin-bottom: 8px;
}
.revive-progress {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.revive-progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(to right, #44ff44, #88ff88);
transition: width 0.3s ease;
}
.wave-announcement {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
font-family: "Creepster", serif;
font-weight: 400;
font-style: normal; font-size: 72px;
color: #ff0000;
text-shadow: 0 0 20px rgba(255, 0, 0, 0.8);
opacity: 0;
transition: transform 0.5s ease, opacity 0.5s ease;
pointer-events: none;
z-index: 1000;
}
.wave-announcement.show {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
animation: pulseAndFade 3s ease-out forwards;
}
@keyframes pulseAndFade {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
20% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
40% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
80% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
}
</style>