From fd57ca974027f8829ac49ea45040f967becc1470 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 19 Aug 2026 07:42:13 +0200 Subject: [PATCH] Fix enemy facing direction and add player damage feedback --- Makefile | 26 +++++++++---- web/index.html | 24 ++++++++++++ web/src/Bat.ts | 8 ++-- web/src/Brute.ts | 4 +- web/src/Crab.ts | 12 +++--- web/src/EnemyProjectile.ts | 2 +- web/src/Frog.ts | 12 +++--- web/src/Game.ts | 80 +++++++++++++++++++++++++++++++++++++- web/src/Player.ts | 30 ++++++++++++-- web/src/Slime.ts | 4 +- 10 files changed, 169 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index a07ac87..c26e99d 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,27 @@ -# ── Wackelpeter container build/deploy ────────────────────────────────────── -# Uses Podman (docker-compose optional). Targets: -# make build – Build image -# make push – Push image to Gitea registry -# make up/down – Start/stop via docker-compose -# make run – Start via podman directly -# make clean – Remove local image +# ── Wackelpeter build/deploy (podman) ─────────────────────────────────────── +# Targets: +# make – Build web frontend locally (npm run build) +# make web – Same as `make` +# make dev – Start Vite dev server +# make build – Build container image (podman) +# make push – Build & push image to registry (podman) +# make up/down – Start/stop via docker-compose +# make run – Start via podman directly +# make clean – Remove local image REGISTRY ?= gitea.korbel.network IMAGE ?= $(REGISTRY)/nico/wackelpeter TAG ?= latest COMPOSE ?= docker-compose -.PHONY: build push up down run clean login +.PHONY: web dev build push up down run clean login + +web: + npm --prefix web install + npm --prefix web run build + +dev: + npm --prefix web run dev build: podman build -t $(IMAGE):$(TAG) . diff --git a/web/index.html b/web/index.html index d2c2474..1d8d229 100644 --- a/web/index.html +++ b/web/index.html @@ -15,6 +15,28 @@ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; font-family: monospace; z-index: 10; } + #damage-vignette { + position: absolute; inset: 0; opacity: 0; pointer-events: none; + background: radial-gradient(ellipse at center, + rgba(255,0,0,0) 40%, rgba(255,0,0,0.35) 100%); + box-shadow: inset 0 0 120px 40px rgba(255,0,0,0.5); + } + #damage-vignette.hurt { animation: hurt-flash 0.45s ease-out; } + @keyframes hurt-flash { + 0% { opacity: 1; } + 100% { opacity: 0; } + } + #low-hp-vignette { + position: absolute; inset: 0; opacity: 0; pointer-events: none; + background: radial-gradient(ellipse at center, + rgba(255,0,0,0.05) 0%, rgba(255,0,0,0.15) 55%, rgba(220,0,0,0.7) 100%); + box-shadow: inset 0 0 160px 60px rgba(220,0,0,0.55); + } + #low-hp-vignette.active { animation: low-hp-pulse 1.1s ease-in-out infinite; } + @keyframes low-hp-pulse { + 0%, 100% { opacity: 0.25; } + 50% { opacity: 0.6; } + } #health-bar-bg { position: absolute; top: 16px; left: 16px; width: 250px; height: 20px; background: rgba(0,0,0,0.6); border: 2px solid #555; @@ -199,6 +221,8 @@
+
+
100
diff --git a/web/src/Bat.ts b/web/src/Bat.ts index 5019e51..90531f1 100644 --- a/web/src/Bat.ts +++ b/web/src/Bat.ts @@ -28,15 +28,15 @@ export class Bat extends Enemy { this.registerFadeMesh(bodyMesh); this.model.add(bodyMesh); - // Head with ears (face toward the player: -Z) + // Head with ears (face toward the player: +Z) const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 10), furMat); - head.position.set(0, 0.15, -0.3); + head.position.set(0, 0.15, 0.3); this.registerFadeMesh(head); this.model.add(head); for (const side of [-1, 1]) { const ear = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.18, 6), furMat); - ear.position.set(side * 0.12, 0.32, -0.25); + ear.position.set(side * 0.12, 0.32, 0.25); this.registerFadeMesh(ear); this.model.add(ear); } @@ -45,7 +45,7 @@ export class Bat extends Enemy { const eyeMat = new THREE.MeshToonMaterial({ color: 0xff2222 }); for (const side of [-1, 1]) { const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 8), eyeMat); - eye.position.set(side * 0.07, 0.16, -0.42); + eye.position.set(side * 0.07, 0.16, 0.42); this.registerFadeMesh(eye); this.model.add(eye); } diff --git a/web/src/Brute.ts b/web/src/Brute.ts index a61012c..3f6812b 100644 --- a/web/src/Brute.ts +++ b/web/src/Brute.ts @@ -53,11 +53,11 @@ export class Brute extends Enemy { this.registerFadeMesh(head); this.model.add(head); - // Glowing eyes (face toward the player: -Z) + // Glowing eyes (face toward the player: +Z) const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 }); for (const side of [-1, 1]) { const eye = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.1, 0.05), eyeMat); - eye.position.set(side * 0.16, 1.9, -0.32); + eye.position.set(side * 0.16, 1.9, 0.32); this.registerFadeMesh(eye); this.model.add(eye); } diff --git a/web/src/Crab.ts b/web/src/Crab.ts index 3bbe884..32d4017 100644 --- a/web/src/Crab.ts +++ b/web/src/Crab.ts @@ -40,16 +40,16 @@ export class Crab extends Enemy { this.model.add(spot); } - // Eye stalks (on the armored front side, -Z) + // Eye stalks (on the armored front side, +Z) const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 }); const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 }); for (const side of [-1, 1]) { const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat); - stalk.position.set(side * 0.22, 0.55, -0.42); + stalk.position.set(side * 0.22, 0.55, 0.42); this.registerFadeMesh(stalk); this.model.add(stalk); const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); - eye.position.set(side * 0.22, 0.68, -0.42); + eye.position.set(side * 0.22, 0.68, 0.42); this.registerFadeMesh(eye); this.model.add(eye); } @@ -70,8 +70,8 @@ export class Crab extends Enemy { const clawGeom = new THREE.SphereGeometry(0.22, 12, 10); this.clawL = new THREE.Mesh(clawGeom, shellMat); this.clawR = new THREE.Mesh(clawGeom, shellMat); - this.clawL.position.set(-0.45, 0.45, -0.5); - this.clawR.position.set(0.45, 0.45, -0.5); + this.clawL.position.set(-0.45, 0.45, 0.5); + this.clawR.position.set(0.45, 0.45, 0.5); this.clawL.scale.set(1.2, 0.7, 1.4); this.clawR.scale.set(1.2, 0.7, 1.4); this.clawL.castShadow = true; @@ -144,7 +144,7 @@ export class Crab extends Enemy { ) { // Armored from the front: reduce damage coming from the facing side if (sourcePos) { - const front = new THREE.Vector3(0, 0, -1) + const front = new THREE.Vector3(0, 0, 1) .applyQuaternion(this.body.quaternion); front.y = 0; front.normalize(); diff --git a/web/src/EnemyProjectile.ts b/web/src/EnemyProjectile.ts index 779f2be..2a2a456 100644 --- a/web/src/EnemyProjectile.ts +++ b/web/src/EnemyProjectile.ts @@ -51,7 +51,7 @@ export class EnemyProjectile { if (game.player.health > 0) { const dist = this.body.position.distanceTo(game.player.body.position); if (dist < HIT_RADIUS) { - game.player.damage(this.damage); + game.player.damage(this.damage, this.body.position); this.dead = true; return false; } diff --git a/web/src/Frog.ts b/web/src/Frog.ts index bd64d4f..9e30570 100644 --- a/web/src/Frog.ts +++ b/web/src/Frog.ts @@ -52,18 +52,18 @@ export class Frog extends Enemy { // Belly const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat); belly.scale.set(0.85, 0.5, 0.85); - belly.position.set(0, 0.3, -0.42); + belly.position.set(0, 0.3, 0.42); this.registerFadeMesh(belly); this.model.add(belly); - // Bulging eyes (face toward the player: -Z) + // Bulging eyes (face toward the player: +Z) const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12); const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8); const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 }); this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat); this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat); - this.eyeL.position.set(-0.24, 0.72, -0.28); - this.eyeR.position.set(0.24, 0.72, -0.28); + this.eyeL.position.set(-0.24, 0.72, 0.28); + this.eyeR.position.set(0.24, 0.72, 0.28); this.eyeL.castShadow = true; this.eyeR.castShadow = true; this.registerFadeMesh(this.eyeL); @@ -72,7 +72,7 @@ export class Frog extends Enemy { for (const eye of [this.eyeL, this.eyeR]) { const pupil = new THREE.Mesh(pupilGeom, pupilMat); - pupil.position.set(0, 0.02, -0.12); + pupil.position.set(0, 0.02, 0.12); this.registerFadeMesh(pupil); eye.add(pupil); } @@ -82,7 +82,7 @@ export class Frog extends Enemy { for (const side of [-1, 1]) { const thigh = new THREE.Mesh(legGeom, greenMat); thigh.scale.set(0.8, 1, 1.2); - thigh.position.set(side * 0.42, 0.25, 0.35); + thigh.position.set(side * 0.42, 0.25, -0.35); thigh.rotation.y = side * 0.5; thigh.castShadow = true; this.registerFadeMesh(thigh); diff --git a/web/src/Game.ts b/web/src/Game.ts index b2f4795..8764af8 100644 --- a/web/src/Game.ts +++ b/web/src/Game.ts @@ -96,8 +96,17 @@ export class Game { private uiOfferIcons: HTMLElement[] = []; private uiOfferTitles: HTMLElement[] = []; private uiOfferDescs: HTMLElement[] = []; + private uiDamageVignette!: HTMLElement; + private uiLowHpVignette!: HTMLElement; private mouseOnCanvas = false; + private bloodParticles: { + mesh: THREE.Mesh; + vel: THREE.Vector3; + life: number; + mat: THREE.MeshBasicMaterial; + }[] = []; + constructor() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x0d1117); @@ -178,6 +187,8 @@ export class Game { this.uiLevelUp = document.getElementById('level-up')!; this.uiConfirmBtn = document.getElementById('btn-confirm') as HTMLButtonElement; this.uiConfirmBtn.addEventListener('click', () => this.confirmOffer()); + this.uiDamageVignette = document.getElementById('damage-vignette')!; + this.uiLowHpVignette = document.getElementById('low-hp-vignette')!; for (let i = 0; i < 3; i++) { const card = document.getElementById(`offer-${i}`)!; @@ -869,6 +880,71 @@ export class Game { if (this.player.health <= 0 && this.state === 'playing') { this.gameOver(); } + + this.uiLowHpVignette.classList.toggle( + 'active', healthPct < 0.3 && healthPct > 0 + ); + } + + onPlayerHurt(sourcePos: THREE.Vector3) { + const el = this.uiDamageVignette; + el.classList.remove('hurt'); + void el.offsetWidth; + el.classList.add('hurt'); + + const away = new THREE.Vector3() + .subVectors(this.player.body.position, sourcePos); + away.y = 0; + if (away.lengthSq() < 0.01) { + away.set(Math.random() - 0.5, 0, Math.random() - 0.5); + } + away.normalize(); + + const geom = new THREE.SphereGeometry(0.07, 6, 6); + const count = 12; + for (let i = 0; i < count; i++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0xcc2222, + transparent: true, + }); + const mesh = new THREE.Mesh(geom, mat); + mesh.position.copy(this.player.body.position); + mesh.position.y += 0.5; + this.scene.add(mesh); + this.bloodParticles.push({ + mesh, + mat, + vel: away.clone() + .multiplyScalar(2 + Math.random() * 3) + .add(new THREE.Vector3( + (Math.random() - 0.5) * 1.5, + 3 + Math.random() * 3, + (Math.random() - 0.5) * 1.5 + )), + life: 0.5 + Math.random() * 0.3, + }); + } + } + + private updateBloodParticles(dt: number) { + for (let i = this.bloodParticles.length - 1; i >= 0; i--) { + const p = this.bloodParticles[i]; + p.life -= dt; + if (p.life <= 0) { + this.scene.remove(p.mesh); + p.mesh.geometry.dispose(); + p.mat.dispose(); + this.bloodParticles.splice(i, 1); + continue; + } + p.vel.y -= 14 * dt; + p.mesh.position.addScaledVector(p.vel, dt); + if (p.mesh.position.y < 0.06) { + p.mesh.position.y = 0.06; + p.vel.set(0, 0, 0); + } + p.mat.opacity = Math.max(0, p.life / 0.6); + } } private animate() { @@ -889,6 +965,8 @@ export class Game { } } + this.updateBloodParticles(dt); + // Update animations this.player.updateAnimations(dt); this.player.rightHandWeapon.updateAnimation(dt); @@ -919,7 +997,7 @@ export class Game { if (enemy.dead) continue; const dist = enemy.body.position.distanceTo(this.player.body.position); if (dist < enemy.contactRadius) { - this.player.damage(enemy.contactDps * dt); + this.player.damage(enemy.contactDps * dt, enemy.body.position); if (thorns > 0) { enemy.takeDamage(thorns * dt, this, false); } diff --git a/web/src/Player.ts b/web/src/Player.ts index cf5d89b..78a9ddf 100644 --- a/web/src/Player.ts +++ b/web/src/Player.ts @@ -45,6 +45,12 @@ export class Player { private clips: Map = new Map(); private currentAnim = ''; private backShield: THREE.Group | null = null; + private bodyMaterials: THREE.MeshToonMaterial[] = []; + private hurtCooldown = 0; + private flashTime = 0; + private static readonly BASE_COLOR = 0x44aa44; + private static readonly HURT_COLOR = 0xff5555; + private static readonly HURT_FLASH_TIME = 0.3; constructor() { this.body = new THREE.Group(); @@ -151,9 +157,10 @@ export class Player { child.castShadow = true; child.receiveShadow = true; const mat = new THREE.MeshToonMaterial({ - color: 0x44aa44, + color: Player.BASE_COLOR, }); child.material = mat; + this.bodyMaterials.push(mat); } }); this.body.add(model); @@ -170,11 +177,12 @@ export class Player { console.warn('Failed to load blob model, using fallback', e); // Fallback capsule const bodyGeom = new THREE.CapsuleGeometry(0.5, 0.5, 8, 16); - const bodyMat = new THREE.MeshToonMaterial({ color: 0x44aa44 }); + const bodyMat = new THREE.MeshToonMaterial({ color: Player.BASE_COLOR }); const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat); bodyMesh.position.y = 0.75; bodyMesh.castShadow = true; this.body.add(bodyMesh); + this.bodyMaterials.push(bodyMat); } } @@ -208,6 +216,15 @@ export class Player { updateAnimations(dt: number) { if (this.mixer) this.mixer.update(dt); + if (this.hurtCooldown > 0) this.hurtCooldown -= dt; + if (this.flashTime > 0) { + this.flashTime -= dt; + const t = Math.max(0, this.flashTime) / Player.HURT_FLASH_TIME; + const color = new THREE.Color(Player.BASE_COLOR).lerp( + new THREE.Color(Player.HURT_COLOR), t + ); + for (const mat of this.bodyMaterials) mat.color.copy(color); + } } setGame(game: Game) { @@ -226,7 +243,7 @@ export class Player { this.health = Math.min(this.health + amount, this.stats.maxHealth); } - damage(amount: number) { + damage(amount: number, sourcePos?: THREE.Vector3) { if (this.shieldHp > 0) { const absorbed = Math.min(this.shieldHp, amount); this.shieldHp -= absorbed; @@ -234,6 +251,13 @@ export class Player { this.shieldTimer = this.stats.shieldRechargeDelay; } this.health -= amount; + if (amount > 0 && this.hurtCooldown <= 0) { + this.hurtCooldown = 0.25; + this.flashTime = Player.HURT_FLASH_TIME; + if (this.game) { + this.game.onPlayerHurt(sourcePos ?? this.body.position); + } + } } updatePassives(dt: number) { diff --git a/web/src/Slime.ts b/web/src/Slime.ts index 330381e..762171a 100644 --- a/web/src/Slime.ts +++ b/web/src/Slime.ts @@ -25,11 +25,11 @@ export class Slime extends Enemy { this.registerFadeMesh(this.bodyMesh); this.model.add(this.bodyMesh); - // Cute eyes (face toward the player: -Z) + // Cute eyes (face toward the player: +Z) const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 }); for (const side of [-1, 1]) { const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat); - eye.position.set(side * 0.18, 0.72, -0.42); + eye.position.set(side * 0.18, 0.72, 0.42); this.registerFadeMesh(eye); this.model.add(eye); }