Compare commits

...
3 Commits
Author SHA1 Message Date
Erzangel 03ddbf08b2 feat: more items, keyboard controls 2026-05-29 18:06:20 +02:00
Erzangel 573085f116 docs: disclaimer for vibe coding 2026-05-29 17:49:41 +02:00
Erzangel f1e4c03ea5 feat: beam of light above boss 2026-05-29 17:49:30 +02:00
3 changed files with 186 additions and 67 deletions
+2
View File
@@ -1,5 +1,7 @@
# Web Adventure - Community RPG
> Disclaimer: 99% of this repository is vibe-coded. Check code & read what follows with discretion.
A multiplayer web-based RPG where players collaborate to defeat a boss monster within a time limit before the world resets.
## Features
+100 -44
View File
@@ -23,7 +23,7 @@ HP_REGEN_INTERVAL = float(os.getenv("HP_REGEN_INTERVAL", "2" if DEBUG_MODE else
HP_REGEN_AMOUNT = 1 # HP healed per interval
MONSTER_SPAWN_RATE = 0.05 # Probability per game tick
MAX_MONSTERS = 50
BOSS_HEALTH_BASE = 1000
BOSS_FIXED_HEALTH = 6000
MIN_LEVEL_FOR_BOSS = 10
BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1
RESOURCE_NODE_COUNT = 540
@@ -34,6 +34,10 @@ ATTACK_RANGE_BASE = 4
WEAPON_SLOT_COUNT = 5
BLOCK_BUILD_COST_WOOD = 2
BLOCK_HEALTH = 5
PLAYER_BASE_ATTACK = 5
PLAYER_BASE_DEFENSE = 2
PLAYER_BASE_MAX_HEALTH = 100
PLAYER_BASE_MOVE_CAPACITY = 1
# Data Models
@dataclass
@@ -187,6 +191,7 @@ class GameWorld:
self.resources: Dict[str, Resource] = {}
self.chests: Dict[str, Chest] = {}
self.boss: Monster = None
self.boss_spawned: bool = False
self.game_start_time: float = 0
self.is_game_active: bool = False
self.connected_sessions: Dict[str, str] = {} # session_id -> player_id
@@ -196,6 +201,8 @@ class GameWorld:
# Auto-start a game session immediately so the timer runs from launch.
self.game_start_time = datetime.now().timestamp()
self.is_game_active = True
self._spawn_boss()
self.boss_spawned = True
def _init_resources(self):
"""Initialize static resources on the map"""
@@ -221,18 +228,18 @@ class GameWorld:
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
weapon_templates = [
{"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2},
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3},
{"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1},
{"name": "Oversized Shrimp", "attack_bonus": 2, "range_bonus": 2},
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 5},
{"name": "Big Ass Axe", "attack_bonus": 3, "range_bonus": 1},
{
"name": "Banner of Vigor",
"name": "Yaoi Paddle",
"attack_bonus": 1,
"range_bonus": 1,
"aura_radius": 6,
"aura_bonuses": {"action_regen": 1, "hp_regen": 1},
},
{
"name": "Warden Standard",
"name": "Warden Guitar",
"attack_bonus": 0,
"range_bonus": 1,
"aura_radius": 6,
@@ -243,7 +250,13 @@ class GameWorld:
"attack_bonus": 0,
"range_bonus": 0,
"aura_radius": 5,
"aura_bonuses": {"free_move_chance": 0.5},
"aura_bonuses": {"free_move_chance": 0.7},
},
{
"name": "Traveler Ring",
"attack_bonus": 0,
"range_bonus": 0,
"movement_bonus": 1,
},
]
self.chests[chest_id] = Chest(
@@ -257,6 +270,7 @@ class GameWorld:
self.game_start_time = datetime.now().timestamp()
self.is_game_active = True
self.boss = None
self.boss_spawned = False
self.monsters.clear()
self.chests.clear()
self.resources.clear()
@@ -270,8 +284,13 @@ class GameWorld:
continue
player.level = 1
player.exp = 0
player.attack = PLAYER_BASE_ATTACK
player.defense = PLAYER_BASE_DEFENSE
player.max_health = PLAYER_BASE_MAX_HEALTH
player.max_action_points = ACTION_POINTS_MAX
player.movement_capacity = PLAYER_BASE_MOVE_CAPACITY
player.health = player.max_health
player.action_points = ACTION_POINTS_MAX
player.action_points = player.max_action_points
player.inventory = {"wood": 0, "stone": 0}
player.weapon_slots = [None] * WEAPON_SLOT_COUNT
player.equipped_weapon_slot = -1
@@ -282,6 +301,9 @@ class GameWorld:
0,
)
self._spawn_boss()
self.boss_spawned = True
def get_elapsed_time(self) -> float:
if not self.is_game_active:
return 0
@@ -300,11 +322,10 @@ class GameWorld:
"""Called periodically to update game state"""
current_time = datetime.now().timestamp()
# Spawn boss if conditions are met
if self.boss is None and len(self.players) > 0:
active_players = [p for p in self.players.values() if p.active]
if active_players and any(p.level >= MIN_LEVEL_FOR_BOSS for p in active_players):
self._spawn_boss()
# Spawn boss once at the beginning of each session.
if self.boss is None and not self.boss_spawned:
self._spawn_boss()
self.boss_spawned = True
# Spawn monsters
if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS:
@@ -357,31 +378,30 @@ class GameWorld:
)
def _spawn_boss(self):
"""Spawn the boss monster"""
boss_id = f"boss_{self.generation}"
active_players = [p for p in self.players.values() if p.active]
avg_level = sum(p.level for p in active_players) / len(active_players)
health = int(BOSS_HEALTH_BASE + avg_level * 500)
"""Spawn the boss monster."""
boss_id = f"boss_{self.generation}"
active_players = [p for p in self.players.values() if p.active]
avg_level = (sum(p.level for p in active_players) / len(active_players)) if active_players else 1
# Spawn boss at a distance from players
if active_players:
player_pos = active_players[0].position
angle = random.random() * 2 * 3.14159
x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1)
y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1)
else:
x = y = 0
# Spawn boss at a distance from players if possible, otherwise at map center.
if active_players:
player_pos = active_players[0].position
angle = random.random() * 2 * 3.14159
x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1)
y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1)
else:
x = y = 0
self.boss = Monster(
id=boss_id,
position=Position(x, y, 0),
health=health,
max_health=health,
level=int(avg_level) + 5,
attack=15 + int(avg_level),
defense=4 + int(avg_level // 2),
is_boss=True,
)
self.boss = Monster(
id=boss_id,
position=Position(x, y, 0),
health=BOSS_FIXED_HEALTH,
max_health=BOSS_FIXED_HEALTH,
level=max(10, int(avg_level) + 5),
attack=15 + int(avg_level),
defense=4 + int(avg_level // 2),
is_boss=True,
)
def _player_effective_attack(player: Player) -> int:
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
@@ -397,6 +417,43 @@ def _player_effective_range(player: Player) -> float:
return player.attack_range + float(w.get("range_bonus", 0))
return player.attack_range
def _player_effective_movement(player: Player) -> int:
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
w = player.weapon_slots[player.equipped_weapon_slot]
if w:
return max(1, player.movement_capacity + int(w.get("movement_bonus", 0)))
return player.movement_capacity
def _recompute_player_stats_for_level(player: Player) -> None:
# Slow progression so levels matter without runaway scaling.
level = max(1, int(player.level))
old_max_health = player.max_health
old_max_ap = player.max_action_points
player.attack = PLAYER_BASE_ATTACK + (level - 1) // 3
player.defense = PLAYER_BASE_DEFENSE + (level - 1) // 4
player.max_health = PLAYER_BASE_MAX_HEALTH + ((level - 1) // 2) * 5
player.max_action_points = ACTION_POINTS_MAX + (level - 1) // 4
if player.max_health > old_max_health:
player.health = min(player.max_health, player.health + (player.max_health - old_max_health))
else:
player.health = min(player.max_health, player.health)
if player.max_action_points > old_max_ap:
player.action_points = min(player.max_action_points, player.action_points + (player.max_action_points - old_max_ap))
else:
player.action_points = min(player.max_action_points, player.action_points)
def _recompute_player_level_from_exp(player: Player) -> None:
level = 1
xp_needed = 30
while player.exp >= xp_needed and level < 100:
level += 1
xp_needed += 20
player.level = level
_recompute_player_stats_for_level(player)
def _player_nearby_bonuses(player: Player) -> Dict:
if "game_world" not in globals():
return {}
@@ -437,6 +494,7 @@ def _serialize_player(player: Player) -> Dict:
bonuses = _player_nearby_bonuses(player)
effective_defense = _player_effective_defense(player)
effective_max_health = _player_effective_max_health(player)
effective_movement = _player_effective_movement(player)
if player.health > effective_max_health:
player.health = effective_max_health
@@ -446,6 +504,8 @@ def _serialize_player(player: Player) -> Dict:
data["base_max_health"] = player.max_health
data["max_health"] = effective_max_health
data["health"] = player.health
data["base_movement_capacity"] = player.movement_capacity
data["movement_capacity"] = effective_movement
data["nearby_bonuses"] = bonuses
return data
@@ -584,7 +644,8 @@ async def move_player(player_id: str, request: Request):
# Calculate distance
distance = (dx ** 2 + dy ** 2) ** 0.5
if distance > player.movement_capacity:
effective_movement = _player_effective_movement(player)
if distance > effective_movement:
raise HTTPException(status_code=400, detail="Movement exceeds capacity")
next_x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x + dx))
@@ -700,13 +761,7 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
if monster.health <= 0:
del game_world.monsters[target_id]
player.exp += monster.level * 10
# Calculate level based on XP: 30 for level 1, +20 per additional level
# Level N requires: 30 + 20*(N-1) total XP
player.level = 1
xp_needed = 30
while player.exp >= xp_needed and player.level < 100:
player.level += 1
xp_needed += 20
_recompute_player_level_from_exp(player)
return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True}
retaliation = apply_retaliation(monster.attack)
@@ -739,6 +794,7 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
if p.active:
p.level += 5
p.exp += 500
_recompute_player_stats_for_level(p)
game_world.boss = None
return {"success": True, "damage": damage, "boss_killed": True}
+84 -23
View File
@@ -263,16 +263,16 @@
.boss-bar-container {
position: absolute;
top: 50%;
top: 10px;
left: 50%;
transform: translate(-50%, -50%);
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
border: 2px solid #ff4444;
border-radius: 5px;
padding: 20px;
padding: 10px 14px;
text-align: center;
display: none;
z-index: 10;
z-index: 30;
}
.boss-bar-container.active {
@@ -467,11 +467,11 @@
</div>
<div class="boss-bar-container" id="bossBar">
<div style="color: #ff4444; font-weight: bold; margin-bottom: 10px;">⚔️ BOSS APPEARED ⚔️</div>
<div style="color: #ff4444; font-weight: bold; margin-bottom: 6px;">Boss</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="bossHealthBar" style="width: 100%; background: #ff4444;"></div>
</div>
<div style="margin-top: 10px;">
<div style="margin-top: 6px;">
<span id="bossHealth">Loading...</span>
</div>
</div>
@@ -498,7 +498,7 @@
<div class="bottom-panel">
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Controls</div>
<div class="controls-help">Each action costs 1 AP. Hover buttons to see details.</div>
<div class="controls-help">Keys: Z/Q/S/D move, A attack, E gather, R loot, 1/2/3/4 build. Arrows keep camera orbit.</div>
<div class="button-group">
<button type="button" class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button>
<button type="button" class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button>
@@ -639,13 +639,14 @@
if (aura.defense) auraParts.push(`+${aura.defense} def`);
if (aura.max_health) auraParts.push(`+${aura.max_health} max HP`);
if (aura.free_move_chance) auraParts.push(`${Math.round(aura.free_move_chance * 100)}% free moves`);
if (w.movement_bonus) auraParts.push(`+${w.movement_bonus} move range`);
const auraLine = auraParts.length > 0
? `<div style="color:#7fd6ff; margin-top:2px; pointer-events:none;">Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}</div>`
: '';
return `<div data-slot="${i}" style="padding:3px 6px;margin:2px 0;border-radius:4px;${border};${bg};cursor:pointer;"
title="Click to equip" class="weapon-slot">
${icon}<b>${w.name}</b>
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng</span>
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng${w.movement_bonus ? ` +${w.movement_bonus}mov` : ''}</span>
${auraLine}
</div>`;
}).join('');
@@ -1066,13 +1067,45 @@
}
function createBossMesh(monster) {
const group = new THREE.Group();
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshPhongMaterial({ color: 0xff0000 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(monster.position.x, 0.75, monster.position.y);
cube.position.set(0, 0.75, 0);
cube.castShadow = true;
scene.add(cube);
ossBossObject = cube;
group.add(cube);
// Vertical light ray so the boss is visible from far away.
const beamGeo = new THREE.CylinderGeometry(0.18, 1.4, 18, 20, 1, true);
const beamMat = new THREE.MeshBasicMaterial({
color: 0xfff2a8,
transparent: true,
opacity: 0.32,
side: THREE.DoubleSide,
depthWrite: false,
});
const beam = new THREE.Mesh(beamGeo, beamMat);
beam.position.set(0, 9, 0);
group.add(beam);
const halo = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.7, 28),
new THREE.MeshBasicMaterial({
color: 0xffe680,
transparent: true,
opacity: 0.38,
side: THREE.DoubleSide,
depthWrite: false,
})
);
halo.rotation.x = -Math.PI / 2;
halo.position.set(0, 0.08, 0);
group.add(halo);
group.position.set(monster.position.x, 0, monster.position.y);
scene.add(group);
ossBossObject = group;
}
function createStructureMesh(structure) {
@@ -1215,7 +1248,7 @@
createBossMesh(gameState.boss);
document.getElementById('bossBar').classList.add('active');
} else {
ossBossObject.position.set(gameState.boss.position.x, 0.75, gameState.boss.position.y);
ossBossObject.position.set(gameState.boss.position.x, 0, gameState.boss.position.y);
ossBossObject.rotation.y += 0.01;
}
document.getElementById('bossHealthBar').style.width = `${gameState.boss.boss_progress}%`;
@@ -1489,21 +1522,49 @@
const key = event.key.toLowerCase();
const rotationStep = 0.12;
const panStep = 1.0;
const hotkeys = ["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d", "a", "e", "r", "1", "2", "3", "4"];
if (["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d"].includes(key)) {
if (hotkeys.includes(key)) {
event.preventDefault();
cameraAutoFollow = false;
}
if (key === 'arrowleft') rotateCameraBy(rotationStep, 0, 0);
else if (key === 'arrowright') rotateCameraBy(-rotationStep, 0, 0);
else if (key === 'arrowup') rotateCameraBy(0, -rotationStep * 0.45, 0);
else if (key === 'arrowdown') rotateCameraBy(0, rotationStep * 0.45, 0);
else if (key === 'z') panCameraBy(0, panStep);
else if (key === 's') panCameraBy(0, -panStep);
else if (key === 'q') panCameraBy(-panStep, 0);
else if (key === 'd') panCameraBy(panStep, 0);
// Camera controls stay on arrows only.
if (key === 'arrowleft') {
cameraAutoFollow = false;
rotateCameraBy(rotationStep, 0, 0);
} else if (key === 'arrowright') {
cameraAutoFollow = false;
rotateCameraBy(-rotationStep, 0, 0);
} else if (key === 'arrowup') {
cameraAutoFollow = false;
rotateCameraBy(0, -rotationStep * 0.45, 0);
} else if (key === 'arrowdown') {
cameraAutoFollow = false;
rotateCameraBy(0, rotationStep * 0.45, 0);
// Movement + actions on nearby keys.
} else if (key === 'z') {
document.getElementById('moveUpBtn').click();
} else if (key === 's') {
document.getElementById('moveDownBtn').click();
} else if (key === 'q') {
document.getElementById('moveLeftBtn').click();
} else if (key === 'd') {
document.getElementById('moveRightBtn').click();
} else if (key === 'a') {
document.getElementById('attackBtn').click();
} else if (key === 'e') {
document.getElementById('gatherBtn').click();
} else if (key === 'r') {
document.getElementById('lootBtn').click();
} else if (key === '1') {
document.getElementById('buildHouseBtn').click();
} else if (key === '2') {
document.getElementById('buildFarmBtn').click();
} else if (key === '3') {
document.getElementById('buildTowerBtn').click();
} else if (key === '4') {
document.getElementById('buildBlockBtn').click();
}
});
}