612 lines
21 KiB
HTML
612 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Elfmeterschießen Simulator</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background-color: #222;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
color: white;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#game-container {
|
|
position: relative;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
border: 4px solid #fff;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
canvas {
|
|
display: block;
|
|
background: linear-gradient(to bottom, #87CEEB 0%, #87CEEB 30%, #2E8B57 30%, #228B22 100%);
|
|
cursor: crosshair;
|
|
}
|
|
|
|
#ui-overlay {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 0;
|
|
width: 100%;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
#score-board {
|
|
font-size: 18px;
|
|
margin-top: 5px;
|
|
font-weight: bold;
|
|
color: #ffeb3b;
|
|
}
|
|
|
|
#message {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 48px;
|
|
font-weight: 900;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
text-shadow: 0 0 10px rgba(0,0,0,0.5);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.instruction {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
width: 100%;
|
|
text-align: center;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
font-size: 14px;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="game-container">
|
|
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
|
|
|
<div id="ui-overlay">
|
|
<h1>Elfmeter Simulator</h1>
|
|
<div id="score-board">Tore: 0 | Versuche: 0</div>
|
|
</div>
|
|
|
|
<div id="message">TOOR!</div>
|
|
<div class="instruction">Klicke irgendwo ins Tor, um zu schießen!</div>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const messageEl = document.getElementById('message');
|
|
const scoreEl = document.getElementById('score-board');
|
|
|
|
// Zuschauer-Daten (statisch)
|
|
const spectators = [];
|
|
|
|
// Spielzustand
|
|
let score = 0;
|
|
let attempts = 0;
|
|
let isBallFlying = false;
|
|
let showMessageTimer = null;
|
|
let frameCount = 0; // Für Animationen
|
|
|
|
// Ball Physik
|
|
const startBallY = 520;
|
|
const startBallX = 400;
|
|
const ball = {
|
|
x: startBallX,
|
|
y: startBallY,
|
|
z: 0,
|
|
radius: 15,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
speed: 0
|
|
};
|
|
|
|
// Torwart (Verbessert)
|
|
const goalie = {
|
|
x: 400,
|
|
baseY: 230, // Bodenlinie
|
|
y: 230, // Aktuelle Y Position
|
|
width: 50, // Körperbreite
|
|
height: 90,
|
|
speed: 1.5,
|
|
direction: 1,
|
|
diveTargetX: 0,
|
|
diveTargetY: 0,
|
|
isDiving: false,
|
|
angle: 0 // Rotation für Hechtsprung
|
|
};
|
|
|
|
// Tordimensionen
|
|
const goal = {
|
|
topY: 130,
|
|
bottomY: 230,
|
|
topLeftX: 250,
|
|
topRightX: 550,
|
|
bottomLeftX: 230,
|
|
bottomRightX: 570
|
|
};
|
|
|
|
// Hilfsfunktionen
|
|
function initSpectators() {
|
|
for (let y = 55; y < 220; y += 20) {
|
|
for (let x = 10; x < canvas.width; x += 15 + Math.random() * 10) {
|
|
spectators.push({
|
|
x: x,
|
|
y: y,
|
|
color: `hsl(${Math.random() * 360}, 70%, 60%)`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function resetBall() {
|
|
isBallFlying = false;
|
|
ball.x = startBallX;
|
|
ball.y = startBallY;
|
|
ball.z = 0;
|
|
ball.radius = 15;
|
|
|
|
// Torwart zurücksetzen
|
|
goalie.isDiving = false;
|
|
goalie.x = 400;
|
|
goalie.y = goalie.baseY;
|
|
goalie.angle = 0;
|
|
}
|
|
|
|
function showStatus(text, color) {
|
|
messageEl.innerText = text;
|
|
messageEl.style.color = color;
|
|
messageEl.style.opacity = 1;
|
|
|
|
clearTimeout(showMessageTimer);
|
|
showMessageTimer = setTimeout(() => {
|
|
messageEl.style.opacity = 0;
|
|
resetBall();
|
|
}, 2000);
|
|
}
|
|
|
|
function updateScore() {
|
|
scoreEl.innerText = `Tore: ${score} | Versuche: ${attempts}`;
|
|
}
|
|
|
|
// --- Zeichnen ---
|
|
|
|
function drawStands() {
|
|
ctx.fillStyle = "#555";
|
|
ctx.fillRect(0, 50, canvas.width, 180);
|
|
|
|
ctx.fillStyle = "#777";
|
|
for (let y = 60; y < 230; y += 20) {
|
|
ctx.fillRect(0, y, canvas.width, 10);
|
|
}
|
|
|
|
for (const spectator of spectators) {
|
|
ctx.fillStyle = spectator.color;
|
|
ctx.beginPath();
|
|
ctx.arc(spectator.x, spectator.y, 5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.fillStyle = "#333";
|
|
ctx.fillRect(0, 0, canvas.width, 50);
|
|
ctx.strokeStyle = "#222";
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 50);
|
|
ctx.lineTo(canvas.width, 50);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawPitch() {
|
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.2)";
|
|
ctx.lineWidth = 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(100, 600);
|
|
ctx.lineTo(200, 230);
|
|
ctx.lineTo(600, 230);
|
|
ctx.lineTo(700, 600);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = "white";
|
|
ctx.beginPath();
|
|
ctx.arc(400, 480, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function drawGoal() {
|
|
ctx.strokeStyle = "white";
|
|
ctx.lineWidth = 8;
|
|
ctx.lineJoin = "round";
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(goal.topLeftX + 20, goal.topY - 10);
|
|
ctx.lineTo(goal.topRightX - 20, goal.topY - 10);
|
|
ctx.lineTo(goal.bottomRightX - 10, goal.bottomY);
|
|
ctx.moveTo(goal.topLeftX + 20, goal.topY - 10);
|
|
ctx.lineTo(goal.bottomLeftX + 10, goal.bottomY);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
|
ctx.lineWidth = 1;
|
|
for (let i = goal.topLeftX; i < goal.topRightX; i += 20) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i, goal.topY);
|
|
ctx.lineTo(i + (i - 400) * 0.2, goal.bottomY);
|
|
ctx.stroke();
|
|
}
|
|
for (let j = goal.topY; j < goal.bottomY; j += 15) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(goal.topLeftX, j);
|
|
ctx.lineTo(goal.topRightX, j);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.strokeStyle = "white";
|
|
ctx.lineWidth = 8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(goal.bottomLeftX, goal.bottomY);
|
|
ctx.lineTo(goal.topLeftX, goal.topY);
|
|
ctx.lineTo(goal.topRightX, goal.topY);
|
|
ctx.lineTo(goal.bottomRightX, goal.bottomY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawGoalie() {
|
|
ctx.save();
|
|
|
|
// Position setzen (Mitte des Torsos ca.)
|
|
let drawX = goalie.x;
|
|
let drawY = goalie.y - 45; // Etwas höher als die Füße
|
|
|
|
ctx.translate(drawX, drawY);
|
|
ctx.rotate(goalie.angle); // Rotation für Hechtsprung
|
|
|
|
// Farben
|
|
const skinColor = "#ffccaa";
|
|
const shirtColor = "#FFD700"; // Gelb
|
|
const shortsColor = "#111"; // Schwarz
|
|
|
|
// --- Zeichne Körperteile (Menschlicher) ---
|
|
|
|
// Kopf
|
|
ctx.fillStyle = skinColor;
|
|
ctx.beginPath();
|
|
ctx.arc(0, -35, 12, 0, Math.PI * 2); // Relativ zur Mitte
|
|
ctx.fill();
|
|
|
|
// Körper (Trikot)
|
|
ctx.fillStyle = shirtColor;
|
|
// Abgerundetes Rechteck für Torso
|
|
ctx.beginPath();
|
|
ctx.roundRect(-18, -25, 36, 45, 5);
|
|
ctx.fill();
|
|
|
|
// Hose
|
|
ctx.fillStyle = shortsColor;
|
|
ctx.beginPath();
|
|
ctx.roundRect(-18, 15, 36, 15, 5);
|
|
ctx.fill();
|
|
|
|
// Gliedmaßen Animation
|
|
ctx.lineCap = "round";
|
|
ctx.lineJoin = "round";
|
|
|
|
// Beine
|
|
ctx.strokeStyle = skinColor; // Beine Hautfarbe oder Socken? Sagen wir schwarz für Stutzen
|
|
ctx.lineWidth = 10;
|
|
|
|
ctx.strokeStyle = "#111"; // Stutzen/Hose Verlängerung
|
|
ctx.beginPath();
|
|
if (goalie.isDiving) {
|
|
// Beine strecken beim Sprung
|
|
ctx.moveTo(-10, 30); ctx.lineTo(-15, 60); // Linkes Bein
|
|
ctx.moveTo(10, 30); ctx.lineTo(15, 60); // Rechtes Bein
|
|
} else {
|
|
// Beine leicht gebeugt beim Stehen (Idle Animation)
|
|
const kneeBend = Math.sin(frameCount * 0.1) * 2;
|
|
ctx.moveTo(-10, 30); ctx.lineTo(-12, 55 + kneeBend);
|
|
ctx.moveTo(10, 30); ctx.lineTo(12, 55 + kneeBend);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Arme (Trikot Farbe Ärmel + Haut)
|
|
// Wir zeichnen vereinfacht ganze Arme in Hautfarbe mit gelben Ärmeln
|
|
|
|
// Ärmel
|
|
ctx.strokeStyle = shirtColor;
|
|
ctx.lineWidth = 12;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-15, -20); ctx.lineTo(-25, -5); // Links
|
|
ctx.moveTo(15, -20); ctx.lineTo(25, -5); // Rechts
|
|
ctx.stroke();
|
|
|
|
// Unterarme / Hände
|
|
ctx.strokeStyle = skinColor;
|
|
ctx.lineWidth = 10;
|
|
ctx.beginPath();
|
|
|
|
if (goalie.isDiving) {
|
|
// Arme weit strecken zum Ball
|
|
// Wenn wir nach rechts springen (angle > 0), ist "oben" relativ zur Rotation
|
|
// Da wir das ganze Canvas rotiert haben, zeichnen wir einfach Arme nach "oben" (über den Kopf)
|
|
ctx.moveTo(-25, -5); ctx.lineTo(-35, -40); // Arme hochreißen
|
|
ctx.moveTo(25, -5); ctx.lineTo(35, -40);
|
|
|
|
// Handschuhe (Weiß)
|
|
ctx.strokeStyle = "white";
|
|
ctx.lineWidth = 14;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-35, -40); ctx.lineTo(-37, -45);
|
|
ctx.moveTo(35, -40); ctx.lineTo(37, -45);
|
|
ctx.stroke();
|
|
|
|
} else {
|
|
// Arme bereit halten (Idle)
|
|
const armWave = Math.cos(frameCount * 0.1) * 3;
|
|
ctx.moveTo(-25, -5); ctx.lineTo(-35 - armWave, 10);
|
|
ctx.moveTo(25, -5); ctx.lineTo(35 + armWave, 10);
|
|
|
|
// Handschuhe
|
|
ctx.strokeStyle = "white";
|
|
ctx.lineWidth = 14;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-35 - armWave, 10); ctx.lineTo(-37 - armWave, 15);
|
|
ctx.moveTo(35 + armWave, 10); ctx.lineTo(37 + armWave, 15);
|
|
ctx.stroke();
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawBall() {
|
|
ctx.save();
|
|
ctx.translate(ball.x, ball.y);
|
|
|
|
if (isBallFlying) {
|
|
const rotation = Date.now() / 100;
|
|
ctx.rotate(rotation);
|
|
}
|
|
|
|
ctx.fillStyle = "white";
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, ball.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = "black";
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, ball.radius * 0.5, 0, Math.PI * 2, false);
|
|
ctx.fill();
|
|
|
|
for(let i=0; i<5; i++) {
|
|
ctx.beginPath();
|
|
ctx.arc(
|
|
Math.cos(i * 2 * Math.PI / 5) * ball.radius * 0.7,
|
|
Math.sin(i * 2 * Math.PI / 5) * ball.radius * 0.7,
|
|
ball.radius * 0.2, 0, Math.PI*2
|
|
);
|
|
ctx.fill();
|
|
}
|
|
|
|
if (!isBallFlying || ball.z < 0.1) {
|
|
ctx.restore();
|
|
ctx.fillStyle = "rgba(0,0,0,0.3)";
|
|
ctx.beginPath();
|
|
ctx.ellipse(ball.x + 5, ball.y + ball.radius + 2, ball.radius, ball.radius * 0.3, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else {
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// --- Logik ---
|
|
|
|
function update() {
|
|
frameCount++;
|
|
|
|
// Torwart Idle Bewegung (Automatisch hin und her)
|
|
if (!isBallFlying && !goalie.isDiving) {
|
|
goalie.x += goalie.speed * goalie.direction;
|
|
|
|
// Leichte Wippbewegung im Stand
|
|
goalie.y = goalie.baseY + Math.sin(frameCount * 0.2) * 2;
|
|
goalie.angle = 0; // Aufrecht stehen
|
|
|
|
if (goalie.x > goal.bottomRightX - 40 || goalie.x < goal.bottomLeftX + 40) {
|
|
goalie.direction *= -1;
|
|
}
|
|
}
|
|
else if (isBallFlying && goalie.isDiving) {
|
|
// HECHT-LOGIK
|
|
|
|
// X Bewegung
|
|
const dx = goalie.diveTargetX - goalie.x;
|
|
goalie.x += dx * 0.08; // Sanfteres Gleiten
|
|
|
|
// Y Bewegung (Sprung)
|
|
const dy = goalie.diveTargetY - goalie.y;
|
|
goalie.y += dy * 0.08;
|
|
|
|
// Rotation berechnen (Hecht)
|
|
// Wenn er weit zur Seite muss (>50px vom Zentrum), dreht er sich
|
|
const distFromCenter = goalie.diveTargetX - 400;
|
|
let targetAngle = 0;
|
|
|
|
if (Math.abs(distFromCenter) > 40) {
|
|
// 90 Grad (PI/2) nach rechts oder links
|
|
// Wir nehmen etwas weniger als 90 Grad für realistischeren Sprung (ca 70 Grad)
|
|
targetAngle = (distFromCenter > 0) ? 1.2 : -1.2;
|
|
} else {
|
|
// Bei zentralen Bällen nur leicht neigen oder aufrecht bleiben
|
|
targetAngle = (distFromCenter > 0) ? 0.2 : -0.2;
|
|
}
|
|
|
|
// Winkel interpolieren
|
|
goalie.angle += (targetAngle - goalie.angle) * 0.1;
|
|
}
|
|
|
|
// Ball Bewegung
|
|
if (isBallFlying) {
|
|
ball.z += 0.05;
|
|
const progress = ball.z;
|
|
|
|
ball.x = startBallX + (ball.targetX - startBallX) * progress;
|
|
ball.y = startBallY + (ball.targetY - startBallY) * progress;
|
|
|
|
ball.radius = 15 - (7 * progress);
|
|
|
|
if (progress >= 1) {
|
|
isBallFlying = false;
|
|
checkResult();
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkResult() {
|
|
// Verbesserte Kollisionserkennung für den neuen Körper
|
|
// Wir prüfen die Distanz zum Zentrum des Torsos
|
|
// Da er sich dreht, ist eine einfache Box schwer, wir nehmen eine etwas größere Kreis-Distanz an
|
|
|
|
// Torso Position anpassen für Hitbox
|
|
let hitX = goalie.x;
|
|
let hitY = goalie.y - 40; // Körpermitte
|
|
|
|
const distX = Math.abs(ball.x - hitX);
|
|
const distY = Math.abs(ball.y - hitY);
|
|
|
|
// Hitbox
|
|
let caught = false;
|
|
|
|
// Körpertreffer
|
|
if (distX < 45 && distY < 45) caught = true;
|
|
|
|
// Optional: Wenn er hechtet, deckt er mehr Breite aber weniger Höhe ab
|
|
// Das simulieren wir einfachheitshalber über den distX Parameter oben, der schon relativ generisch ist.
|
|
|
|
if (caught) {
|
|
showStatus("GEHALTEN!", "#ff4444");
|
|
return;
|
|
}
|
|
|
|
// Tor Check
|
|
const inGoalX = ball.x > goal.topLeftX && ball.x < goal.topRightX;
|
|
const inGoalY = ball.y > goal.topY && ball.y < goal.bottomY;
|
|
|
|
if (inGoalX && inGoalY) {
|
|
score++;
|
|
showStatus("TOOOOR!", "#44ff44");
|
|
} else {
|
|
showStatus("VORBEI!", "#aaaaaa");
|
|
}
|
|
|
|
updateScore();
|
|
}
|
|
|
|
function gameLoop() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawStands();
|
|
|
|
// Boden
|
|
ctx.fillStyle = "#2E8B57";
|
|
ctx.fillRect(0, 230, canvas.width, canvas.height - 230);
|
|
|
|
drawPitch();
|
|
drawGoal();
|
|
|
|
// Torwart zeichnen (Reihenfolge wichtig für Tiefe)
|
|
// Wenn Ball hinten ist (z nahe 1), ist Ball HINTER Torwart? Nein, Ball fliegt auf Tor zu.
|
|
// Im echten Leben steht Torwart VOR der Linie.
|
|
// Also: Ball (wenn weit weg) -> Torwart -> Ball (wenn nah)
|
|
// Hier einfachheitshalber: Torwart vor Netz, Ball davor.
|
|
|
|
drawGoalie();
|
|
drawBall();
|
|
|
|
update();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// Input Handler
|
|
function handleInput(x, y) {
|
|
if (isBallFlying || messageEl.style.opacity == "1") return;
|
|
|
|
isBallFlying = true;
|
|
attempts++;
|
|
updateScore();
|
|
|
|
ball.targetX = x;
|
|
ball.targetY = y;
|
|
|
|
// KI Entscheidung
|
|
goalie.isDiving = true;
|
|
|
|
// Fehlerquote
|
|
const errorMargin = (Math.random() * 200) - 100;
|
|
|
|
// Ziel für den Torwart setzen
|
|
goalie.diveTargetX = ball.targetX + errorMargin;
|
|
|
|
// Er soll auch springen (Y-Achse)!
|
|
// Ziel-Y ist Ballhöhe, aber begrenzt durch Boden
|
|
// Wir addieren etwas Offset, da er "hoch" springen muss um an hohe Bälle zu kommen
|
|
let jumpHeight = ball.targetY;
|
|
if (jumpHeight > goalie.baseY) jumpHeight = goalie.baseY; // Nicht in den Boden graben
|
|
|
|
goalie.diveTargetY = jumpHeight + 40; // +40 weil seine Koordinaten bei den Füßen sind
|
|
|
|
// Begrenzen
|
|
if (goalie.diveTargetX < goal.bottomLeftX - 50) goalie.diveTargetX = goal.bottomLeftX - 50;
|
|
if (goalie.diveTargetX > goal.bottomRightX + 50) goalie.diveTargetX = goal.bottomRightX + 50;
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const clickX = (e.clientX - rect.left) * scaleX;
|
|
const clickY = (e.clientY - rect.top) * scaleY;
|
|
handleInput(clickX, clickY);
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const touch = e.touches[0];
|
|
const clickX = (touch.clientX - rect.left) * scaleX;
|
|
const clickY = (touch.clientY - rect.top) * scaleY;
|
|
handleInput(clickX, clickY);
|
|
}, {passive: false});
|
|
|
|
initSpectators();
|
|
updateScore();
|
|
gameLoop();
|
|
|
|
</script>
|
|
</body>
|
|
</html> |