siteMissionListener !== null) { $this->engine->bridge->removeListener('mission-at-address', $this->siteMissionListener); $this->siteMissionListener = null; } // Cancel outstanding geocoding and tile responses from previous missions. ++$this->geoGeneration; $this->missionTiles?->stop(); $this->missionTiles = null; $this->missionTerrain = null; $this->geoElapsed = 0; $this->missionAddress = trim((string) ($params['address'] ?? '')); $this->geoState = $this->missionAddress === '' ? 'none' : 'search'; $this->geoMessage = ''; $this->releaseShadows(); $this->time = $this->scroll = $this->fireDelay = $this->spawnDelay = $this->bossDelay = 0; $this->bonusDelay = 9; $this->speedBoost = $this->rapidBoost = $this->muzzleFlash = 0; $this->bonuses = []; $this->bonusCount = 0; $this->x = 160; $this->y = 405; $this->bossX = 160; $this->bossY = -90; $this->invulnerable = 2; $this->lives = 3; $this->score = 0; $this->bossHp = 100; $this->phase = 'wave'; $this->paused = false; $this->demo = !empty($params['demo']); $this->enemies = $this->shots = $this->sparks = []; if (isset($params['testBonus']) && in_array($params['testBonus'], ['speed', 'rapid'], true)) { $this->bonuses[] = ['x' => $this->x, 'y' => $this->y, 'type' => $params['testBonus']]; } $this->spawnCount = 0; if (!empty($params['boss'])) { $this->phase = 'boss'; $this->bossY = 85; $this->bossHp = (int)($params['bossHp'] ?? 100); } // Flight mode can pre-load the Hurricane before the combat scene. // Load each asset independently so the other planes are never skipped. foreach ([ 'combat-hurricane-ww2' => 'assets/sprites/hurricane-mk1-atlas.png', 'combat-junkers' => 'assets/sprites/junkers-atlas.png', 'combat-ground' => 'assets/tiles/ign-beauce-2023.png', 'combat-bf109' => 'assets/sprites/bf109-e4-atlas.png', 'combat-ju87' => 'assets/sprites/ju87-b2-atlas.png', 'combat-effects' => 'assets/sprites/effects-atlas.png', ] as $name => $path) { if (!$this->sdl()->hasTexture($name)) { $this->sdl()->loadTexture($name, $path); } } $this->hurricaneTex = $this->sdl()->getTexture('combat-hurricane-ww2')['texture']; $this->junkersTex = $this->sdl()->getTexture('combat-junkers')['texture']; $this->bf109Tex = $this->sdl()->getTexture('combat-bf109')['texture']; $this->ju87Tex = $this->sdl()->getTexture('combat-ju87')['texture']; $this->groundTex = $this->sdl()->getTexture('combat-ground')['texture']; $this->effectsTex = $this->sdl()->getTexture('combat-effects')['texture']; $this->siteMissionListener = function (array $event): void { if ($this->scenes()->current() !== $this) { return; } $address = trim((string) ($event['address'] ?? '')); if ($address !== '') { $this->enter(['address' => $address]); } }; $this->engine->bridge->on('mission-at-address', $this->siteMissionListener); if ($this->missionAddress !== '') { if (isset($params['latitude'], $params['longitude']) && is_numeric($params['latitude']) && is_numeric($params['longitude'])) { // Pre-resolved coordinates: used on restart and in deterministic tests. $this->startAtCoordinates( (float) $params['latitude'], (float) $params['longitude'], (string) ($params['label'] ?? $this->missionAddress) ); } else { $generation = $this->geoGeneration; (new AddressLookup($this->engine->http()))->search( $this->missionAddress, function (float $lat, float $lon, string $label) use ($generation): void { if ($generation === $this->geoGeneration) { $this->startAtCoordinates($lat, $lon, $label); } }, function (string $message) use ($generation): void { if ($generation === $this->geoGeneration) { $this->geoState = 'error'; $this->geoMessage = $message; } } ); } } } public function exit(): void { if ($this->siteMissionListener !== null) { $this->engine->bridge->removeListener('mission-at-address', $this->siteMissionListener); $this->siteMissionListener = null; } ++$this->geoGeneration; $this->missionTiles?->stop(); $this->missionTiles = null; $this->releaseShadows(); } private function startAtCoordinates(float $lat, float $lon, string $label): void { if ($lat < 41.0 || $lat > 51.5 || $lon < -6.0 || $lon > 10.0) { $this->geoState = 'error'; $this->geoMessage = 'Adresse hors de France metropolitaine'; return; } $this->missionTiles?->stop(); $this->missionTerrain = new MissionTerrain($lat, $lon, $label); $this->missionTiles = new OrthoTileManager($this->engine->http(), $this->sdl()); $this->geoElapsed = 0.0; $this->geoState = 'loading'; $this->geoMessage = 'Chargement des photographies IGN'; $this->createShadows(); } private function createShadows(): void { $this->releaseShadows(); $this->playerShadowTex = AircraftShadow::create($this->sdl(), $this->engine->config->path('assets/sprites/hurricane-mk1-atlas.png'), 512, 64, 'mission-shadow-hurricane'); $this->bf109ShadowTex = AircraftShadow::create($this->sdl(), $this->engine->config->path('assets/sprites/bf109-e4-atlas.png'), 512, 64, 'mission-shadow-bf109'); $this->ju87ShadowTex = AircraftShadow::create($this->sdl(), $this->engine->config->path('assets/sprites/ju87-b2-atlas.png'), 512, 64, 'mission-shadow-ju87'); $this->bossShadowTex = AircraftShadow::create($this->sdl(), $this->engine->config->path('assets/sprites/junkers-atlas.png'), 512, 64, 'mission-shadow-boss', 0, 0, 256); } private function releaseShadows(): void { foreach (['mission-shadow-hurricane', 'mission-shadow-bf109', 'mission-shadow-ju87', 'mission-shadow-boss'] as $name) { if ($this->sdl()->hasTexture($name)) { $this->sdl()->releaseTexture($name); } } $this->playerShadowTex = $this->bf109ShadowTex = null; $this->ju87ShadowTex = $this->bossShadowTex = null; } /** Refresh tile requests while the level scrolls through the same real-world area. */ private function updateTerrainTiles(): void { if ($this->missionTerrain === null || $this->missionTiles === null) { return; } [$cameraX, $cameraY] = $this->missionTerrain->camera($this->scroll); $this->missionTiles->tick($cameraX, $cameraY, $this->width(), $this->height(), $this->geoElapsed); } public function update(float $dt): void { // Reopen the same native address field while keeping combat and map modes separate. if ($this->actions()->isPressed('search_address')) { $this->scenes()->switchTo(AddressEntryScene::class, ['purpose' => 'mission', 'address' => $this->missionAddress], false); return; } if ($this->geoState === 'error') { if ($this->actions()->isPressed('confirm') || $this->actions()->isPressed('back')) { $this->scenes()->switchTo(AddressEntryScene::class, ['purpose' => 'mission', 'address' => $this->missionAddress], false); } return; } if ($this->geoState === 'search' || $this->geoState === 'loading') { $this->geoElapsed += $dt; if ($this->geoState === 'loading') { $this->updateTerrainTiles(); if (($this->missionTiles?->readyCount() ?? 0) >= 2) { $this->geoState = 'ready'; $this->geoMessage = ''; } elseif ($this->geoElapsed >= 12.0) { // Never pretend a cached demonstration image is the chosen address. // Keep the game playable with a persistent explicit warning. $this->geoState = 'fallback'; $this->geoMessage = 'IGN INDISPONIBLE - F POUR CHANGER'; } } return; // No enemies or mission timer while locating and loading terrain. } if ($this->actions()->isPressed('pause')) $this->paused = !$this->paused; if ($this->paused) return; if ($this->phase === 'victory' || $this->phase === 'defeat') { if ($this->actions()->isPressed('confirm') || $this->actions()->isPressed('fire')) { $restart = $this->missionAddress === '' ? [] : ['address' => $this->missionAddress]; if ($this->missionTerrain !== null) { $restart['latitude'] = $this->missionTerrain->latitude; $restart['longitude'] = $this->missionTerrain->longitude; $restart['label'] = $this->missionTerrain->label; } $this->enter($restart); } return; } $this->time += $dt; // 250 source pixels/s ≈ 50 m/s in metropolitan France; old demo remains unchanged. $this->scroll += $dt * ($this->missionTerrain === null ? 75.0 : 250.0); $this->geoElapsed += $dt; $this->updateTerrainTiles(); if ($this->geoState === 'fallback' && ($this->missionTiles?->readyCount() ?? 0) >= 2) { $this->geoState = 'ready'; $this->geoMessage = ''; } $this->invulnerable = max(0, $this->invulnerable - $dt); $this->speedBoost = max(0, $this->speedBoost - $dt); $this->rapidBoost = max(0, $this->rapidBoost - $dt); $this->muzzleFlash = max(0, $this->muzzleFlash - $dt); $dx = $this->demo ? sin($this->time * 1.4) * 0.6 : $this->actions()->axis('left', 'right'); $dy = $this->demo ? 0 : $this->actions()->axis('up', 'down'); $speed = $this->speedBoost > 0 ? 255 : 175; $this->x = max(28, min(292, $this->x + $dx * $speed * $dt)); $this->y = max(100, min(440, $this->y + $dy * $speed * $dt)); $this->fireDelay -= $dt; if (($this->demo || $this->actions()->isHeld('fire')) && $this->fireDelay <= 0) { // Four visible muzzle positions stand for the Hurricane's wing gun groups. foreach ([-22, -13, 13, 22] as $offset) { $this->shots[] = ['x' => $this->x + $offset, 'y' => $this->y - 17, 'vy' => -400, 'friendly' => true]; } $this->muzzleFlash = 0.07; $this->fireDelay = $this->rapidBoost > 0 ? 0.09 : 0.19; } $this->bonusDelay -= $dt; if ($this->bonusDelay <= 0 && $this->phase === 'wave') { $this->bonuses[] = ['x' => 55 + ($this->bonusCount * 97) % 210, 'y' => -18, 'type' => $this->bonusCount % 2 === 0 ? 'speed' : 'rapid']; $this->bonusCount++; $this->bonusDelay = 11; } foreach ($this->bonuses as $i => &$bonus) { $bonus['y'] += 88 * $dt; if ($bonus['y'] > 500) { unset($this->bonuses[$i]); continue; } if (Hitbox::overlap($bonus['x'] - 12, $bonus['y'] - 12, 24, 24, $this->x - 15, $this->y - 15, 30, 30)) { if ($bonus['type'] === 'speed') $this->speedBoost = 8; else $this->rapidBoost = 8; $this->score += 25; $this->burst($bonus['x'], $bonus['y']); unset($this->bonuses[$i]); } } unset($bonus); if ($this->phase === 'wave') { $this->spawnDelay -= $dt; if ($this->spawnDelay <= 0) { $lane = ($this->spawnCount * 3 + 1) % 5; $type = ['bf109', 'ju87', 'bf109', 'bf109', 'ju87'][$this->spawnCount % 5]; $this->enemies[] = EnemyProfile::spawn($type, 40 + $lane * 60); $this->spawnCount++; $this->spawnDelay = 1.15; } if ($this->time >= 50) { $this->phase = 'boss'; $this->enemies = []; } } else { $this->bossY = min(85, $this->bossY + 45 * $dt); $this->bossX = 160 + sin($this->time * 0.8) * 60; $this->bossDelay -= $dt; if ($this->bossY >= 80 && $this->bossDelay <= 0) { foreach ([-36, 0, 36] as $offset) $this->shots[] = ['x' => $this->bossX + $offset, 'y' => $this->bossY + 34, 'vy' => 160, 'friendly' => false]; $this->bossDelay = 1.5; } } foreach ($this->enemies as $i => &$enemy) { EnemyProfile::step($enemy, $dt); if ($enemy['fireDelay'] <= 0 && $enemy['y'] > 24 && $enemy['y'] < 310) { $this->shots[] = ['x' => $enemy['x'], 'y' => $enemy['y'] + $enemy['hitHeight'] / 2, 'vy' => $enemy['type'] === 'bf109' ? 145 : 110, 'friendly' => false]; $enemy['fireDelay'] = $enemy['type'] === 'bf109' ? 1.8 : 2.8; } if ($enemy['y'] > 500) unset($this->enemies[$i]); else { [$ex, $ey, $ew, $eh] = EnemyProfile::hitbox($enemy); if ($this->invulnerable <= 0 && Hitbox::overlap($ex, $ey, $ew, $eh, $this->x - 15, $this->y - 15, 30, 30)) $this->damage(); } } unset($enemy); foreach ($this->shots as $i => &$shot) { $shot['y'] += $shot['vy'] * $dt; if ($shot['y'] < -10 || $shot['y'] > 490) { unset($this->shots[$i]); continue; } if (!$shot['friendly']) { if ($this->invulnerable <= 0 && Hitbox::overlap($shot['x'] - 3, $shot['y'] - 5, 6, 10, $this->x - 15, $this->y - 15, 30, 30)) { unset($this->shots[$i]); $this->damage(); } continue; } foreach ($this->enemies as $j => &$enemy) { [$ex, $ey, $ew, $eh] = EnemyProfile::hitbox($enemy); if (!Hitbox::overlap($shot['x'] - 2, $shot['y'] - 7, 4, 14, $ex, $ey, $ew, $eh)) continue; unset($this->shots[$i]); $this->burst($shot['x'], $shot['y']); if (--$enemy['hp'] <= 0) { $this->score += $enemy['type'] === 'bf109' ? 100 : 250; unset($this->enemies[$j]); } break; } unset($enemy); if ($this->phase === 'boss' && isset($this->shots[$i]) && $this->bossY >= 70 && Hitbox::overlap($shot['x'] - 2, $shot['y'] - 7, 4, 14, $this->bossX - 75, $this->bossY - 35, 150, 70)) { unset($this->shots[$i]); $this->burst($shot['x'], $shot['y']); if (--$this->bossHp <= 0) { $this->phase = 'victory'; $this->score += 2000; $this->burst($this->bossX, $this->bossY); } } } unset($shot); foreach ($this->sparks as $i => &$spark) { $spark['life'] -= $dt; $spark['x'] += $spark['vx'] * $dt; $spark['y'] += $spark['vy'] * $dt; if ($spark['life'] <= 0) unset($this->sparks[$i]); } unset($spark); } private function damage(): void { $this->burst($this->x, $this->y); $this->invulnerable = 2; if (--$this->lives <= 0) $this->phase = 'defeat'; } private function burst(float $x, float $y): void { for ($i = 0; $i < 8; $i++) { $a = $i * 0.785; $this->sparks[] = ['x' => $x, 'y' => $y, 'vx' => cos($a) * 60, 'vy' => sin($a) * 60, 'life' => 0.45]; } } /** Cast the Blender alpha silhouettes onto the orthophoto, below the planes. */ private function drawMissionShadows(Renderer $r): void { if ($this->missionTerrain === null) { return; } // A fixed NW sunlight direction and high sun (68 degrees). $metersPerPixel = $this->missionTerrain->metersPerPixel(); $offsetScale = 1.0 / tan(deg2rad(68)) / $metersPerPixel; $bearing = deg2rad(135); // Shadow SE, opposite the NW sunlight. $offset = static fn (float $altitude): array => [ sin($bearing) * $altitude * $offsetScale, -cos($bearing) * $altitude * $offsetScale, ]; foreach ($this->enemies as $enemy) { $texture = $enemy['type'] === 'bf109' ? $this->bf109ShadowTex : $this->ju87ShadowTex; if ($texture === null) { continue; } [$ox, $oy] = $offset($enemy['type'] === 'bf109' ? 24.0 : 20.0); $r->copy($texture, null, $enemy['x'] - $enemy['width'] / 2 + $ox, $enemy['y'] - $enemy['height'] / 2 + $oy, $enemy['width'], $enemy['height']); } if ($this->phase === 'boss' && $this->bossShadowTex !== null) { [$ox, $oy] = $offset(35.0); $r->copy($this->bossShadowTex, null, $this->bossX - 80 + $ox, $this->bossY - 40 + $oy, 160, 80); } if ($this->playerShadowTex !== null) { [$ox, $oy] = $offset(15.0); $r->copy($this->playerShadowTex, null, $this->x - 30 + $ox, $this->y - 30 + $oy, 60, 60); } } private function asciiLabel(string $text): string { $text = strtr($text, [ 'é'=>'e', 'è'=>'e', 'ê'=>'e', 'É'=>'E', 'à'=>'a', 'À'=>'A', 'ç'=>'c', 'Ç'=>'C', 'î'=>'i', 'ô'=>'o', 'ù'=>'u', 'û'=>'u', ]); return strtoupper(substr(preg_replace('/[^\x20-\x7E]/', ' ', $text) ?? '', 0, 42)); } public function render(Renderer $r): void { $r->clear([38, 63, 53]); $offset = fmod($this->scroll, 960); for ($i = -1; $i < 2; $i++) { $y = $offset + $i * 960; $r->copy($this->groundTex, null, 0, $y, 320, 480); $r->copy($this->groundTex, null, 0, $y + 480, 320, 480, false, true); } if ($this->missionTerrain !== null && $this->missionTiles !== null) { [$cameraX, $cameraY] = $this->missionTerrain->camera($this->scroll); $this->missionTiles->draw($r, $cameraX, $cameraY, $this->width(), $this->height()); } $r->fillRect(0, 0, 320, 480, [10, 28, 23, 70]); $this->drawMissionShadows($r); foreach ($this->enemies as $e) { $texture = $e['type'] === 'bf109' ? $this->bf109Tex : $this->ju87Tex; $r->copy($texture, EnemyProfile::frame($e), $e['x'] - $e['width'] / 2, $e['y'] - $e['height'] / 2, $e['width'], $e['height']); } if ($this->phase === 'boss') { $damageFrame = $this->bossHp < 50 ? 1 : 0; $r->copy($this->junkersTex, [$damageFrame * 512, 0, 512, 256], $this->bossX - 80, $this->bossY - 40, 160, 80); } foreach ($this->bonuses as $bonus) { $tile = $bonus['type'] === 'speed' ? 128 : 192; $r->copy($this->effectsTex, [$tile, 0, 64, 64], $bonus['x'] - 13, $bonus['y'] - 13, 26, 26); } foreach ($this->shots as $s) { $r->copy($this->effectsTex, [$s['friendly'] ? 0 : 64, 0, 64, 64], $s['x'] - 5, $s['y'] - 10, 10, 20); } if ($this->invulnerable <= 0 || (int)($this->time * 10) % 2 === 0) { $bankFrame = $this->demo ? 1 : ($this->actions()->isHeld('left') ? 0 : ($this->actions()->isHeld('right') ? 2 : 1)); $propFrame = ((int)($this->time * 18)) % 3; $r->copy($this->hurricaneTex, [$propFrame * 512, $bankFrame * 512, 512, 512], $this->x - 30, $this->y - 30, 60, 60); if ($this->muzzleFlash > 0) { foreach ([-22, -13, 13, 22] as $offset) { $r->copy($this->effectsTex, [256, 0, 64, 64], $this->x + $offset - 6, $this->y - 26, 12, 12); } } } foreach ($this->sparks as $s) $r->fillRect($s['x'], $s['y'], 4, 4, [255, 177, 63]); $r->fillRect(0, 0, 320, 31, [20, 30, 34, 225]); $this->font()->draw($r, 'LIVES ' . $this->lives . ' SCORE ' . $this->score, 7, 8, 1, [255, 248, 215]); if ($this->speedBoost > 0 || $this->rapidBoost > 0) { $boost = $this->speedBoost > 0 ? 'SPEED ' . (int) ceil($this->speedBoost) : ''; if ($this->rapidBoost > 0) $boost .= ($boost === '' ? '' : ' ') . 'RAPID ' . (int) ceil($this->rapidBoost); $this->font()->draw($r, $boost, 314, 8, 1, [144, 241, 208], 'right'); } $this->font()->draw($r, '(C) IGN - BD ORTHO', 315, 466, 1, [255, 248, 215], 'right', [0, 0, 0]); if ($this->missionTerrain !== null) { $r->fillRect(0, 31, 320, 18, [17, 28, 32, 217]); $zone = $this->asciiLabel($this->missionTerrain->label); $line = $this->geoState === 'fallback' ? 'IGN HS - F: ADRESSE' : 'ZONE: ' . substr($zone, 0, 32); $this->font()->draw($r, $line, 6, 36, 1, [250, 231, 185]); } if ($this->phase === 'boss') { $bossBarY = $this->missionTerrain !== null ? 57 : 40; $r->fillRect(55, $bossBarY, 210, 9, [27, 31, 31]); $r->fillRect(57, $bossBarY + 2, 206 * $this->bossHp / 100, 5, [217, 79, 58]); $this->font()->draw($r, 'JUNKERS G 38', 160, $bossBarY + 14, 1, [255, 248, 215], 'center'); } if ($this->phase === 'victory' || $this->phase === 'defeat') { $r->fillRect(20, 190, 280, 90, [18, 28, 32, 235]); $this->font()->draw($r, $this->phase === 'victory' ? 'VICTORY' : 'MISSION FAILED', 160, 212, 2, [255, 230, 180], 'center'); $this->font()->draw($r, 'ENTER OR SPACE TO RESTART', 160, 253, 1, [220, 220, 210], 'center'); } if ($this->geoState === 'search' || $this->geoState === 'loading' || $this->geoState === 'error') { $r->fillRect(16, 178, 288, 126, [11, 25, 32, 241]); $r->drawRect(16, 178, 288, 126, [244, 210, 142]); $title = match ($this->geoState) { 'search' => 'RECHERCHE DE L ADRESSE', 'loading' => 'CHARGEMENT PHOTOS IGN', default => 'ADRESSE INTROUVABLE', }; $this->font()->draw($r, $title, 160, 202, 1, [255, 234, 168], 'center'); $message = $this->asciiLabel($this->geoMessage); $this->font()->draw($r, substr($message, 0, 36), 160, 232, 1, [230, 229, 208], 'center'); if ($this->geoState === 'error') { $this->font()->draw($r, 'ENTREE OU F : AUTRE ADRESSE', 160, 270, 1, [255, 242, 201], 'center'); } } if ($this->paused) { $r->fillRect(30, 206, 260, 65, [18, 28, 32, 235]); $this->font()->draw($r, 'PAUSED', 160, 221, 2, [255, 230, 180], 'center'); $this->font()->draw($r, 'ESC TO RESUME', 160, 253, 1, [220, 220, 210], 'center'); } } }