|
import streamlit as st |
|
import numpy as np |
|
import pandas as pd |
|
import time |
|
import random |
|
from PIL import Image, ImageDraw |
|
import math |
|
import uuid |
|
import base64 |
|
from io import BytesIO |
|
|
|
|
|
st.set_page_config( |
|
page_title="Battle Simulator", |
|
page_icon="🎮", |
|
layout="wide", |
|
initial_sidebar_state="expanded" |
|
) |
|
|
|
|
|
MAP_WIDTH = 800 |
|
MAP_HEIGHT = 600 |
|
TEAM_RED = "red" |
|
TEAM_BLUE = "blue" |
|
UNIT_TYPES = ["infantry", "tank", "artillery"] |
|
TEAM_COLORS = { |
|
TEAM_RED: "#e74c3c", |
|
TEAM_BLUE: "#3498db" |
|
} |
|
UNIT_STATS = { |
|
"infantry": { |
|
"max_health": 80, |
|
"attack": 8, |
|
"defense": 3, |
|
"range": 40, |
|
"speed": 1.5, |
|
"size": 10, |
|
"shape": "circle", |
|
"respawn_time": 120 |
|
}, |
|
"tank": { |
|
"max_health": 150, |
|
"attack": 15, |
|
"defense": 10, |
|
"range": 60, |
|
"speed": 0.8, |
|
"size": 12, |
|
"shape": "square", |
|
"respawn_time": 240 |
|
}, |
|
"artillery": { |
|
"max_health": 60, |
|
"attack": 25, |
|
"defense": 2, |
|
"range": 120, |
|
"speed": 0.5, |
|
"size": 13, |
|
"shape": "triangle", |
|
"respawn_time": 180 |
|
} |
|
} |
|
|
|
|
|
def calculate_distance(x1, y1, x2, y2): |
|
"""Calculate Euclidean distance between two points""" |
|
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) |
|
|
|
def angle_between(x1, y1, x2, y2): |
|
"""Calculate angle between two points in radians""" |
|
return math.atan2(y2 - y1, x2 - x1) |
|
|
|
class Unit: |
|
def __init__(self, id, team, unit_type, x, y): |
|
self.id = id |
|
self.team = team |
|
self.type = unit_type |
|
self.x = x |
|
self.y = y |
|
self.target_x = x |
|
self.target_y = y |
|
|
|
|
|
stats = UNIT_STATS[unit_type] |
|
self.max_health = stats["max_health"] |
|
self.health = self.max_health |
|
self.attack = stats["attack"] |
|
self.defense = stats["defense"] |
|
self.range = stats["range"] |
|
self.speed = stats["speed"] |
|
self.size = stats["size"] |
|
self.shape = stats["shape"] |
|
self.respawn_time = stats["respawn_time"] |
|
|
|
|
|
self.state = "idle" |
|
self.target = None |
|
self.dead_timer = 0 |
|
self.attack_cooldown = 0 |
|
self.attack_cooldown_max = 60 |
|
self.attack_line = None |
|
|
|
def find_target(self, units): |
|
"""Find nearest enemy unit""" |
|
if self.state == "dead": |
|
return |
|
|
|
nearest_dist = float("inf") |
|
nearest_enemy = None |
|
|
|
for unit in units: |
|
if unit.team != self.team and unit.state != "dead": |
|
dist = calculate_distance(self.x, self.y, unit.x, unit.y) |
|
if dist < nearest_dist: |
|
nearest_dist = dist |
|
nearest_enemy = unit |
|
|
|
self.target = nearest_enemy |
|
|
|
def update(self, units): |
|
"""Update unit state and position""" |
|
if self.state == "dead": |
|
self.dead_timer += 1 |
|
if self.dead_timer >= self.respawn_time: |
|
self.respawn() |
|
return |
|
|
|
|
|
if not self.target or self.target.state == "dead": |
|
self.find_target(units) |
|
self.state = "idle" |
|
|
|
|
|
if self.target: |
|
dist_to_target = calculate_distance(self.x, self.y, self.target.x, self.target.y) |
|
|
|
|
|
if dist_to_target <= self.range: |
|
self.attack_target() |
|
self.state = "attacking" |
|
else: |
|
|
|
self.target_x = self.target.x |
|
self.target_y = self.target.y |
|
self.state = "moving" |
|
else: |
|
|
|
if random.random() < 0.02: |
|
team_zone = 300 if self.team == TEAM_RED else 500 |
|
center_x = 200 if self.team == TEAM_RED else 600 |
|
|
|
self.target_x = max(50, min(MAP_WIDTH - 50, |
|
center_x + (random.random() - 0.5) * team_zone)) |
|
self.target_y = max(50, min(MAP_HEIGHT - 50, |
|
300 + (random.random() - 0.5) * 400)) |
|
|
|
|
|
self.move() |
|
|
|
|
|
if self.attack_cooldown > 0: |
|
self.attack_cooldown -= 1 |
|
|
|
|
|
if self.attack_line: |
|
self.attack_line["duration"] -= 1 |
|
if self.attack_line["duration"] <= 0: |
|
self.attack_line = None |
|
|
|
def move(self): |
|
"""Move unit toward target position""" |
|
if self.state == "dead": |
|
return |
|
|
|
dx = self.target_x - self.x |
|
dy = self.target_y - self.y |
|
dist = math.sqrt(dx*dx + dy*dy) |
|
|
|
if dist > 5: |
|
self.x += (dx / dist) * self.speed |
|
self.y += (dy / dist) * self.speed |
|
|
|
|
|
self.x = max(self.size, min(MAP_WIDTH - self.size, self.x)) |
|
self.y = max(self.size, min(MAP_HEIGHT - self.size, self.y)) |
|
|
|
def attack_target(self): |
|
"""Attack the current target""" |
|
if self.attack_cooldown == 0 and self.target and self.target.state != "dead": |
|
|
|
damage_multiplier = 0.8 + random.random() * 0.4 |
|
base_damage = self.attack * damage_multiplier |
|
final_damage = max(1, base_damage - self.target.defense / 2) |
|
|
|
|
|
self.target.take_damage(final_damage) |
|
|
|
|
|
self.attack_line = { |
|
"from_x": self.x, |
|
"from_y": self.y, |
|
"to_x": self.target.x, |
|
"to_y": self.target.y, |
|
"duration": 10 |
|
} |
|
|
|
|
|
self.attack_cooldown = self.attack_cooldown_max |
|
|
|
def take_damage(self, amount): |
|
"""Take damage from an attack""" |
|
self.health -= amount |
|
if self.health <= 0: |
|
self.die() |
|
|
|
def die(self): |
|
"""Unit death""" |
|
self.state = "dead" |
|
self.health = 0 |
|
self.dead_timer = 0 |
|
self.attack_line = None |
|
|
|
def respawn(self): |
|
"""Respawn the unit""" |
|
margin = 50 |
|
if self.team == TEAM_RED: |
|
self.x = margin + random.random() * 100 |
|
else: |
|
self.x = MAP_WIDTH - margin - random.random() * 100 |
|
|
|
self.y = 100 + random.random() * 400 |
|
self.health = self.max_health |
|
self.state = "idle" |
|
self.target = None |
|
self.attack_line = None |
|
self.dead_timer = 0 |
|
self.attack_cooldown = 0 |
|
|
|
def is_point_inside(self, point_x, point_y): |
|
"""Check if a point is inside the unit""" |
|
if self.state == "dead": |
|
return False |
|
|
|
return calculate_distance(self.x, self.y, point_x, point_y) <= self.size |
|
|
|
class BattleSimulation: |
|
def __init__(self): |
|
self.units = [] |
|
self.selected_unit = None |
|
self.frame_count = 0 |
|
|
|
|
|
self.create_units() |
|
|
|
def create_units(self): |
|
"""Create initial units for both teams""" |
|
|
|
for i in range(5): |
|
self.units.append(Unit( |
|
id=f"red-infantry-{i}", |
|
team=TEAM_RED, |
|
unit_type="infantry", |
|
x=50 + random.random() * 100, |
|
y=100 + i * 100 |
|
)) |
|
|
|
for i in range(3): |
|
self.units.append(Unit( |
|
id=f"red-tank-{i}", |
|
team=TEAM_RED, |
|
unit_type="tank", |
|
x=100 + random.random() * 100, |
|
y=150 + i * 150 |
|
)) |
|
|
|
for i in range(2): |
|
self.units.append(Unit( |
|
id=f"red-artillery-{i}", |
|
team=TEAM_RED, |
|
unit_type="artillery", |
|
x=50 + random.random() * 80, |
|
y=200 + i * 200 |
|
)) |
|
|
|
|
|
for i in range(5): |
|
self.units.append(Unit( |
|
id=f"blue-infantry-{i}", |
|
team=TEAM_BLUE, |
|
unit_type="infantry", |
|
x=650 + random.random() * 100, |
|
y=100 + i * 100 |
|
)) |
|
|
|
for i in range(3): |
|
self.units.append(Unit( |
|
id=f"blue-tank-{i}", |
|
team=TEAM_BLUE, |
|
unit_type="tank", |
|
x=600 + random.random() * 100, |
|
y=150 + i * 150 |
|
)) |
|
|
|
for i in range(2): |
|
self.units.append(Unit( |
|
id=f"blue-artillery-{i}", |
|
team=TEAM_BLUE, |
|
unit_type="artillery", |
|
x=670 + random.random() * 80, |
|
y=200 + i * 200 |
|
)) |
|
|
|
def update(self): |
|
"""Update all units in the simulation""" |
|
for unit in self.units: |
|
unit.update(self.units) |
|
|
|
self.frame_count += 1 |
|
|
|
def render(self): |
|
"""Render the current state of the battle""" |
|
|
|
img = Image.new('RGBA', (MAP_WIDTH, MAP_HEIGHT), (232, 244, 229, 255)) |
|
draw = ImageDraw.Draw(img) |
|
|
|
|
|
|
|
|
|
for x in range(250): |
|
alpha = int(50 * (1 - x / 250)) |
|
draw.line([(x, 0), (x, MAP_HEIGHT)], fill=(231, 76, 60, alpha), width=1) |
|
|
|
|
|
for x in range(MAP_WIDTH - 250, MAP_WIDTH): |
|
alpha = int(50 * (x - (MAP_WIDTH - 250)) / 250) |
|
draw.line([(x, 0), (x, MAP_HEIGHT)], fill=(52, 152, 219, alpha), width=1) |
|
|
|
|
|
for unit in self.units: |
|
if unit.attack_line: |
|
line_color = TEAM_COLORS[unit.team] |
|
draw.line( |
|
[(unit.attack_line["from_x"], unit.attack_line["from_y"]), |
|
(unit.attack_line["to_x"], unit.attack_line["to_y"])], |
|
fill=line_color, |
|
width=2 |
|
) |
|
|
|
|
|
for unit in self.units: |
|
if unit.state == "dead": |
|
continue |
|
|
|
|
|
fill_color = TEAM_COLORS[unit.team] |
|
|
|
if unit.shape == "circle": |
|
draw.ellipse( |
|
[(unit.x - unit.size, unit.y - unit.size), |
|
(unit.x + unit.size, unit.y + unit.size)], |
|
fill=fill_color |
|
) |
|
elif unit.shape == "square": |
|
draw.rectangle( |
|
[(unit.x - unit.size, unit.y - unit.size), |
|
(unit.x + unit.size, unit.y + unit.size)], |
|
fill=fill_color |
|
) |
|
elif unit.shape == "triangle": |
|
draw.polygon( |
|
[(unit.x, unit.y - unit.size), |
|
(unit.x - unit.size, unit.y + unit.size), |
|
(unit.x + unit.size, unit.y + unit.size)], |
|
fill=fill_color |
|
) |
|
|
|
|
|
health_ratio = unit.health / unit.max_health |
|
health_bar_width = 20 |
|
health_bar_height = 3 |
|
|
|
|
|
draw.rectangle( |
|
[(unit.x - health_bar_width/2, unit.y - 20), |
|
(unit.x + health_bar_width/2, unit.y - 20 + health_bar_height)], |
|
fill="#e74c3c" |
|
) |
|
|
|
|
|
draw.rectangle( |
|
[(unit.x - health_bar_width/2, unit.y - 20), |
|
(unit.x - health_bar_width/2 + health_bar_width * health_ratio, unit.y - 20 + health_bar_height)], |
|
fill="#2ecc71" |
|
) |
|
|
|
|
|
state_colors = { |
|
"idle": "#95a5a6", |
|
"moving": "#3498db", |
|
"attacking": "#e74c3c" |
|
} |
|
|
|
draw.ellipse( |
|
[(unit.x - 3, unit.y - 25 - 3), |
|
(unit.x + 3, unit.y - 25 + 3)], |
|
fill=state_colors.get(unit.state, "#95a5a6") |
|
) |
|
|
|
|
|
if self.selected_unit and unit.id == self.selected_unit.id: |
|
draw.ellipse( |
|
[(unit.x - unit.size - 5, unit.y - unit.size - 5), |
|
(unit.x + unit.size + 5, unit.y + unit.size + 5)], |
|
outline="#f1c40f", |
|
width=2 |
|
) |
|
|
|
|
|
buffered = BytesIO() |
|
img.save(buffered, format="PNG") |
|
img_str = base64.b64encode(buffered.getvalue()).decode() |
|
|
|
return img_str |
|
|
|
def handle_click(self, x, y): |
|
"""Handle click on the battle map""" |
|
self.selected_unit = None |
|
for unit in self.units: |
|
if unit.is_point_inside(x, y) and unit.state != "dead": |
|
self.selected_unit = unit |
|
break |
|
|
|
return self.selected_unit |
|
|
|
def get_unit_info_html(unit): |
|
"""Generate HTML for unit info panel""" |
|
if not unit: |
|
return "" |
|
|
|
type_icon = { |
|
"infantry": "⭕", |
|
"tank": "⬛", |
|
"artillery": "△" |
|
}.get(unit.type, "") |
|
|
|
team_color = TEAM_COLORS[unit.team] |
|
|
|
html = f""" |
|
<div style="background-color: rgba(255, 255, 255, 0.95); padding: 20px; |
|
border-radius: 8px; border: 2px solid {team_color}; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);"> |
|
<h3 style="margin-bottom: 15px; color: #333; border-bottom: 1px solid #ddd; padding-bottom: 10px;"> |
|
{unit.team.capitalize()} {unit.type.capitalize()} {type_icon} |
|
</h3> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Health:</span> <span>{int(unit.health)}/{unit.max_health}</span> |
|
</p> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Attack:</span> <span>{unit.attack}</span> |
|
</p> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Defense:</span> <span>{unit.defense}</span> |
|
</p> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Range:</span> <span>{unit.range}</span> |
|
</p> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Speed:</span> <span>{unit.speed}</span> |
|
</p> |
|
<p style="margin-bottom: 10px; display: flex; justify-content: space-between;"> |
|
<span>Status:</span> <span>{unit.state.capitalize()}</span> |
|
</p> |
|
</div> |
|
""" |
|
return html |
|
|
|
def main(): |
|
|
|
if 'battle_sim' not in st.session_state: |
|
st.session_state.battle_sim = BattleSimulation() |
|
st.session_state.last_click = None |
|
|
|
|
|
st.title("Battle Simulator") |
|
st.markdown("An interactive battle simulation where red and blue forces fight on a dynamic battlefield.") |
|
|
|
|
|
col1, col2 = st.columns([3, 1]) |
|
|
|
with col1: |
|
|
|
battle_sim = st.session_state.battle_sim |
|
battle_sim.update() |
|
img_str = battle_sim.render() |
|
|
|
|
|
st.markdown(f""" |
|
<div style="position: relative; width: {MAP_WIDTH}px; margin: 0 auto;"> |
|
<img src="data:image/png;base64,{img_str}" width="{MAP_WIDTH}" id="battlemap" |
|
style="border: 1px solid #ccc; cursor: pointer;" onclick="handleMapClick(event)"> |
|
</div> |
|
<script> |
|
function handleMapClick(e) { |
|
const rect = e.target.getBoundingClientRect(); |
|
const x = e.clientX - rect.left; |
|
const y = e.clientY - rect.top; |
|
// Pass click coordinates to Streamlit |
|
window.parent.postMessage({{ |
|
type: "streamlit:setComponentValue", |
|
value: [x, y] |
|
}}, "*"); |
|
} |
|
</script> |
|
""", unsafe_allow_html=True) |
|
|
|
|
|
map_click = st.empty() |
|
|
|
with col2: |
|
|
|
st.markdown("### Unit Information") |
|
|
|
unit_info = st.empty() |
|
if battle_sim.selected_unit: |
|
unit_info.markdown(get_unit_info_html(battle_sim.selected_unit), unsafe_allow_html=True) |
|
else: |
|
unit_info.markdown("Click on a unit to see its details") |
|
|
|
|
|
st.markdown("### Unit Types") |
|
st.markdown(""" |
|
- ⭕ **Infantry**: Fast but weaker units |
|
- ⬛ **Tank**: Slow but powerful with strong defense |
|
- △ **Artillery**: Long-range attacks but vulnerable |
|
""") |
|
|
|
|
|
st.markdown("### Battle Statistics") |
|
|
|
|
|
red_active = sum(1 for unit in battle_sim.units if unit.team == TEAM_RED and unit.state != "dead") |
|
blue_active = sum(1 for unit in battle_sim.units if unit.team == TEAM_BLUE and unit.state != "dead") |
|
|
|
|
|
st.markdown(f""" |
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;"> |
|
<div style="text-align: center; padding: 10px; background-color: rgba(231, 76, 60, 0.2); border-radius: 5px; width: 45%;"> |
|
<div style="font-size: 24px; font-weight: bold;">{red_active}</div> |
|
<div>Red Forces</div> |
|
</div> |
|
<div style="text-align: center; padding: 10px; background-color: rgba(52, 152, 219, 0.2); border-radius: 5px; width: 45%;"> |
|
<div style="font-size: 24px; font-weight: bold;">{blue_active}</div> |
|
<div>Blue Forces</div> |
|
</div> |
|
</div> |
|
""", unsafe_allow_html=True) |
|
|
|
|
|
clicked = st.button('Refresh Battle State', key='refresh_battle') |
|
|
|
|
|
click_container = st.container() |
|
with click_container: |
|
click_data = st.text_input("Click coordinates", |
|
value="", |
|
label_visibility="collapsed", |
|
key="click_data") |
|
|
|
if click_data: |
|
try: |
|
x, y = map(float, click_data.strip('[]').split(',')) |
|
selected = battle_sim.handle_click(x, y) |
|
st.session_state.last_click = (x, y) |
|
st.experimental_rerun() |
|
except: |
|
pass |
|
|
|
|
|
st.markdown(""" |
|
<script> |
|
// Set up to receive messages from the iframe |
|
window.addEventListener('message', function(e) { |
|
if (e.data.type === 'streamlit:setComponentValue') { |
|
const coordinates = e.data.value; |
|
document.querySelector('input[key="click_data"]').value = coordinates; |
|
document.querySelector('input[key="click_data"]').dispatchEvent(new Event('input')); |
|
} |
|
}); |
|
|
|
// Auto-refresh the simulation every 1 second |
|
const refreshInterval = setInterval(function() { |
|
const refreshButton = document.querySelector('button[key="refresh_battle"]'); |
|
if (refreshButton) { |
|
refreshButton.click(); |
|
} |
|
}, 1000); |
|
</script> |
|
""", unsafe_allow_html=True) |
|
|
|
if __name__ == "__main__": |
|
main() |