Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5472522429 | ||
|
|
a443433a58 | ||
|
|
5a1985a2b5 | ||
|
|
95238a2e84 | ||
|
|
8797f9e3d3 | ||
|
|
b6d364901a | ||
|
|
434471dfc1 | ||
|
|
6512110eb0 | ||
|
|
1401d9909c | ||
|
|
8fbb12e52e | ||
|
|
7e52faf831 |
No files matched your search
@@ -47,7 +47,8 @@ src/GameLauncher/
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, GameScopedServices, interfaces
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF)
|
||||
```
|
||||
|
||||
@@ -151,6 +152,39 @@ enregistrées localement.
|
||||
Si le dossier a disparu ou est presque vide → réinstallation via l'archive ZIP dans le même
|
||||
dossier. Sinon → vérification complète + téléchargement des seuls fichiers défectueux.
|
||||
|
||||
La réparation contrôle aussi les **raccourcis du Bureau** du jeu et l'entrée « Applications
|
||||
installées » de Windows (voir « Raccourcis du Bureau » ci-dessous) : s'ils désignent un ancien
|
||||
emplacement du launcher, ils sont redirigés vers le launcher actuel (même hors ligne).
|
||||
|
||||
### Raccourcis du Bureau et déplacement du launcher
|
||||
Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher.exe --game <GameId>`),
|
||||
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
|
||||
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
|
||||
| Launcher déplacé ou renommé, raccourci vers l'ancien emplacement (fichier disparu) | Au prochain démarrage du launcher depuis son nouvel emplacement : raccourci redirigé automatiquement, `LauncherPath` et bouton « Désinstaller » de Windows mis à jour |
|
||||
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
|
||||
| Icône personnalisée par le joueur | Conservée lors de la redirection |
|
||||
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert. » (inchangé) |
|
||||
|
||||
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
|
||||
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
|
||||
launcher est enregistré dans le registre (`LauncherPath`, `UninstallString`) pour chaque jeu
|
||||
installé et mis à jour à chaque démarrage. Les paramètres (`%AppData%`) ne dépendent pas de
|
||||
l'emplacement du launcher ; un `launcher.json` posé à côté de l'exe doit être déplacé avec lui.
|
||||
|
||||
Lancer le jeu, avec ou sans raccourci, ne démarre pas une seconde copie du jeu si son exécutable
|
||||
tourne déjà (lancé directement ou avant un redémarrage du launcher).
|
||||
|
||||
**Communication entre instances** : canal nommé local réservé à l'utilisateur courant
|
||||
(`PipeOptions.CurrentUserOnly`, nom propre à la session et à l'utilisateur), une seule commande
|
||||
bornée (256 octets, 2 s) contenant uniquement un identifiant de jeu, validé puis comparé aux jeux
|
||||
de `launcher.json`. Aucun chemin ni argument n'est transmis. Le canal est fermé avant le
|
||||
redémarrage du launcher lors de sa mise à jour, puis recréé par la nouvelle version.
|
||||
|
||||
### Localiser le jeu
|
||||
Sélection d'un dossier : l'exécutable est recherché dans le dossier et ses sous-dossiers
|
||||
(2 niveaux). Le jeu est enregistré dans le registre puis analysé : s'il diffère de la dernière
|
||||
@@ -234,18 +268,18 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|
||||
rien ne change pour un jeu déjà installé avec la version mono-jeu.
|
||||
* Les jeux sont vérifiés en parallèle ; une pastille sur la vignette signale une mise à jour,
|
||||
une opération en cours ou un problème. Un message concernant un jeu sélectionne ce jeu.
|
||||
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu).
|
||||
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu et le lance s'il est prêt).
|
||||
* Fermer le launcher pendant des téléchargements demande une seule confirmation pour tous les jeux.
|
||||
|
||||
### Options de la ligne de commande
|
||||
|
||||
| Option | Rôle |
|
||||
|---|---|
|
||||
| `--game <GameId>` | Ouvre le launcher sur ce jeu |
|
||||
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
|
||||
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
|
||||
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
|
||||
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7) |
|
||||
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
|
||||
|
||||
## 6. Bon à savoir
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Core.Models;
|
||||
@@ -17,16 +19,22 @@ namespace GameLauncher;
|
||||
/// <summary>
|
||||
/// Arguments de la ligne de commande :
|
||||
/// --uninstall [GameId] désinstallation (bouton « Désinstaller » de Windows)
|
||||
/// --game GameId ouvre le launcher sur ce jeu (raccourcis du Bureau)
|
||||
/// --game GameId raccourci du Bureau : ouvre le launcher sur ce jeu, vérifie les mises à jour
|
||||
/// puis lance le jeu s'il est prêt (réglages « Réduire » / « Fermer le launcher »
|
||||
/// respectés) ; si le launcher est déjà ouvert, la demande lui est transmise
|
||||
/// --config chemin utilise un autre launcher.json (tests)
|
||||
/// --capture dossier enregistre une capture PNG par jeu puis quitte (documentation)
|
||||
/// --updated pid nom version (interne) démarrage d'une nouvelle version après mise à jour du launcher
|
||||
/// --launch (interne, avec --updated) le jeu de --game doit être lancé : mise à jour du
|
||||
/// launcher faite au démarrage depuis un raccourci
|
||||
/// </summary>
|
||||
public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
private Mutex? _singleInstance;
|
||||
private bool _ownsMutex;
|
||||
private string? _mutexName;
|
||||
private InstanceChannel? _instanceChannel;
|
||||
private LauncherCatalog? _catalog;
|
||||
private CommandLine? _args;
|
||||
private LauncherViewModel? _launcher;
|
||||
private Views.MainWindow? _mainWindow;
|
||||
@@ -68,6 +76,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
|
||||
Log.Init(Path.Combine(catalog.DataDirectory, "logs"));
|
||||
_catalog = catalog;
|
||||
|
||||
_launcherPath = Environment.ProcessPath ?? Path.Combine(AppContext.BaseDirectory, "GameLauncher.exe");
|
||||
_version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0);
|
||||
@@ -90,11 +99,25 @@ public partial class App : Application, ILauncherRestartHost
|
||||
_mutexName = catalog.SingleInstanceName;
|
||||
if (!AcquireSingleInstance(args.UpdatedFromPid is null ? TimeSpan.Zero : TimeSpan.FromSeconds(15)))
|
||||
{
|
||||
if (args.UpdatedFromPid is null)
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
|
||||
else
|
||||
if (args.UpdatedFromPid is not null)
|
||||
{
|
||||
Log.Warn("Mise à jour du launcher : une autre instance a été ouverte entre-temps.");
|
||||
Shutdown(args.UpdatedFromPid is null ? 0 : 4);
|
||||
Shutdown(4);
|
||||
return;
|
||||
}
|
||||
|
||||
// Raccourci du Bureau (--game) alors que le launcher est déjà ouvert : la demande lui est
|
||||
// transmise (il lance le jeu s'il est prêt et pas déjà lancé), puis cette instance se ferme.
|
||||
// Sans argument --game (ou s'il ne répond pas) : simple message, comme auparavant.
|
||||
if (!string.IsNullOrWhiteSpace(args.GameId) && !args.Uninstall &&
|
||||
await InstanceChannel.TryRequestLaunchAsync(catalog.SingleInstanceName, args.GameId, TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -128,6 +151,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Écoute des demandes des raccourcis du Bureau ouverts pendant que le launcher est affiché.
|
||||
if (args.CaptureDirectory is null && _mutexName is not null && _ownsMutex)
|
||||
{
|
||||
_instanceChannel = new InstanceChannel(_mutexName, OnLaunchRequestedByAnotherInstance);
|
||||
_instanceChannel.Start();
|
||||
}
|
||||
|
||||
// Après une mise à jour : prévenir l'ancienne instance que tout va bien
|
||||
if (args.UpdatedFromPid is { } previousPid)
|
||||
_ = ConfirmLauncherUpdatedAsync(previousPid, args.UpdatedEventName);
|
||||
@@ -140,7 +170,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
if (args.CaptureDirectory is not null)
|
||||
await CaptureAsync(window, args.CaptureDirectory);
|
||||
else
|
||||
await _launcher!.InitializeAsync(args.Uninstall, args.UninstallGameId);
|
||||
await _launcher!.InitializeAsync(args.Uninstall, args.UninstallGameId, args.LaunchGameId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -294,6 +324,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_launcher?.Dispose();
|
||||
_http?.Dispose();
|
||||
ReleaseSingleInstance();
|
||||
@@ -321,6 +353,55 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return _ownsMutex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demande transmise par une autre instance (raccourci du Bureau). Appelé hors du thread de
|
||||
/// l'interface : la demande y est simplement planifiée, la réponse est immédiate.
|
||||
/// </summary>
|
||||
/// <returns>Vrai si le jeu fait partie de ce launcher.</returns>
|
||||
private bool OnLaunchRequestedByAnotherInstance(string gameId)
|
||||
{
|
||||
var known = _catalog?.Find(gameId) is not null;
|
||||
Dispatcher.InvokeAsync(() => HandleLaunchRequest(gameId));
|
||||
return known;
|
||||
}
|
||||
|
||||
private void HandleLaunchRequest(string gameId)
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Info($"Raccourci ouvert alors que le launcher est déjà affiché : {gameId}");
|
||||
if (_launcher is null || _mainWindow is null) return;
|
||||
if (_updates?.IsInstalling == true) return; // le launcher redémarre dans sa nouvelle version
|
||||
|
||||
// Une boîte de dialogue est ouverte (confirmation, choix de dossier…) : on ne lance rien
|
||||
// derrière elle, la fenêtre revient simplement au premier plan.
|
||||
if (!IsMainWindowInteractive())
|
||||
{
|
||||
_mainWindow.Activate();
|
||||
Log.Info("Une boîte de dialogue est ouverte : le jeu n'est pas lancé.");
|
||||
return;
|
||||
}
|
||||
|
||||
_launcher.LaunchFromShortcut(gameId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Demande de lancement depuis un raccourci", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fenêtre principale visible et non bloquée par une fenêtre modale (WPF ou Windows).</summary>
|
||||
private bool IsMainWindowInteractive()
|
||||
{
|
||||
if (_mainWindow is not { IsVisible: true }) return false;
|
||||
var handle = new WindowInteropHelper(_mainWindow).Handle;
|
||||
return handle != IntPtr.Zero && IsWindowEnabled(handle);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool IsWindowEnabled(IntPtr hWnd);
|
||||
|
||||
private void ReleaseSingleInstance()
|
||||
{
|
||||
if (_singleInstance is null) return;
|
||||
@@ -397,6 +478,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
void ILauncherRestartHost.ReleaseForRestart()
|
||||
{
|
||||
_mainWindow?.Hide();
|
||||
_instanceChannel?.Stop(); // la nouvelle version recréera le canal
|
||||
ReleaseSingleInstance();
|
||||
}
|
||||
|
||||
@@ -404,6 +486,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
if (_mutexName is not null && !AcquireSingleInstance(TimeSpan.FromSeconds(2)))
|
||||
Log.Warn("Le verrou d'instance unique n'a pas pu être repris après l'échec de la mise à jour.");
|
||||
else
|
||||
_instanceChannel?.Start();
|
||||
if (_mainWindow is not null)
|
||||
{
|
||||
_mainWindow.Show();
|
||||
@@ -422,6 +506,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var arguments = new List<string>();
|
||||
var gameId = _launcher?.SelectedGame?.GameId ?? _args?.GameId;
|
||||
if (!string.IsNullOrWhiteSpace(gameId)) arguments.AddRange(new[] { "--game", gameId });
|
||||
|
||||
// Mise à jour du launcher faite au démarrage (fenêtre principale pas encore créée) depuis un
|
||||
// raccourci : la nouvelle version lancera le jeu. Après une mise à jour demandée depuis les
|
||||
// paramètres, elle affiche seulement le jeu sélectionné.
|
||||
if (_launcher is null && _args?.LaunchGameId is not null) arguments.Add("--launch");
|
||||
if (_args?.ConfigPath is { Length: > 0 } config) arguments.AddRange(new[] { "--config", Path.GetFullPath(config) });
|
||||
return arguments;
|
||||
}
|
||||
@@ -449,6 +538,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
public CommandLine(IReadOnlyList<string> args)
|
||||
{
|
||||
var launchAfterUpdate = false;
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
@@ -474,13 +564,26 @@ public partial class App : Application, ILauncherRestartHost
|
||||
UpdatedEventName = Next();
|
||||
UpdatedExpectedVersion = Next();
|
||||
break;
|
||||
case "--launch":
|
||||
launchAfterUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --game lance le jeu (raccourci du Bureau). Après une mise à jour du launcher (--updated),
|
||||
// seulement si l'ancienne version le demande explicitement (--launch) : une mise à jour
|
||||
// lancée depuis les paramètres ne doit pas démarrer le jeu.
|
||||
if (!string.IsNullOrWhiteSpace(GameId) && !Uninstall && CaptureDirectory is null &&
|
||||
(UpdatedFromPid is null || launchAfterUpdate))
|
||||
LaunchGameId = GameId;
|
||||
}
|
||||
|
||||
public bool Uninstall { get; }
|
||||
public string? UninstallGameId { get; }
|
||||
public string? GameId { get; }
|
||||
|
||||
/// <summary>Jeu à lancer une fois le launcher prêt (raccourci du Bureau), sinon null.</summary>
|
||||
public string? LaunchGameId { get; }
|
||||
public string? ConfigPath { get; }
|
||||
public string? CaptureDirectory { get; }
|
||||
public int? UpdatedFromPid { get; }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
|
||||
une version STRICTEMENT supérieure à la leur.
|
||||
-->
|
||||
<Version>1.1.3</Version>
|
||||
<Version>1.2.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using GameLauncher.Core.Utils;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Canal de communication entre deux instances du launcher (canal nommé local).
|
||||
///
|
||||
/// Le launcher déjà ouvert écoute. Une seconde instance lancée avec <c>--game <GameId></c>
|
||||
/// (raccourci du Bureau) lui transmet la demande de lancement du jeu, puis se ferme.
|
||||
///
|
||||
/// Sécurité :
|
||||
/// • <see cref="PipeOptions.CurrentUserOnly"/> des deux côtés : seul l'utilisateur courant peut
|
||||
/// se connecter au launcher, et la seconde instance refuse un canal créé par un autre utilisateur ;
|
||||
/// • nom propre à la session Windows et à l'utilisateur (SID) ;
|
||||
/// • une seule commande, de taille bornée (256 octets) et limitée dans le temps (2 s) ;
|
||||
/// • seul un identifiant de jeu est transmis, jamais un chemin ni des arguments : le launcher
|
||||
/// ne lance que les jeux déclarés dans son launcher.json.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class InstanceChannel : IDisposable
|
||||
{
|
||||
private const string LaunchCommand = "LAUNCH";
|
||||
private const string ReplyAccepted = "OK";
|
||||
private const string ReplyUnknownGame = "UNKNOWN";
|
||||
private const string ReplyError = "ERROR";
|
||||
private const int MaxMessageBytes = 256;
|
||||
private const int MaxGameIdLength = 64;
|
||||
private static readonly TimeSpan IoTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly Func<string, bool> _onLaunchRequest;
|
||||
private readonly object _sync = new();
|
||||
private CancellationTokenSource? _cts;
|
||||
private NamedPipeServerStream? _server;
|
||||
|
||||
/// <param name="instanceName">Nom du verrou d'instance unique (ex. <c>Local\Éditeur.Launcher</c>).</param>
|
||||
/// <param name="onLaunchRequest">
|
||||
/// Appelé (hors du thread de l'interface) avec un identifiant de jeu au format valide.
|
||||
/// Retourne vrai si le jeu est connu de ce launcher.
|
||||
/// </param>
|
||||
public InstanceChannel(string instanceName, Func<string, bool> onLaunchRequest)
|
||||
{
|
||||
_pipeName = BuildPipeName(instanceName);
|
||||
_onLaunchRequest = onLaunchRequest;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Côté launcher ouvert (serveur)
|
||||
|
||||
/// <summary>Commence à écouter les demandes des autres instances.</summary>
|
||||
public void Start()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_cts is not null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
var cts = _cts;
|
||||
_ = Task.Run(() => ListenAsync(cts));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arrête l'écoute et ferme le canal (ex. avant le redémarrage du launcher dans sa nouvelle
|
||||
/// version, qui doit pouvoir recréer le canal).
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
NamedPipeServerStream? server;
|
||||
lock (_sync)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts = null;
|
||||
server = _server;
|
||||
_server = null;
|
||||
}
|
||||
try
|
||||
{
|
||||
server?.Dispose(); // interrompt l'attente de connexion en cours
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Fermeture du canal entre instances : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private async Task ListenAsync(CancellationTokenSource cts)
|
||||
{
|
||||
var token = cts.Token;
|
||||
var failures = 0;
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
NamedPipeServerStream server;
|
||||
try
|
||||
{
|
||||
// Une seule connexion à la fois ; si le nom est déjà pris (autre programme), la création échoue.
|
||||
server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
failures = 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Le canal précédent est peut-être encore en cours de fermeture : quelques nouvelles tentatives.
|
||||
if (++failures >= 5)
|
||||
{
|
||||
Log.Warn($"Canal entre instances indisponible : {ex.Message}. " +
|
||||
"Un raccourci du Bureau ne pourra pas lancer le jeu tant que ce launcher est ouvert.");
|
||||
return;
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), token);
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
server.Dispose();
|
||||
return;
|
||||
}
|
||||
_server = server;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await server.WaitForConnectionAsync(token);
|
||||
await HandleClientAsync(server, token);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
// Client déconnecté, délai dépassé ou canal fermé par Stop() : on attend le suivant.
|
||||
if (!token.IsCancellationRequested) Log.Warn($"Canal entre instances : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_server, server)) _server = null;
|
||||
}
|
||||
try { server.Dispose(); }
|
||||
catch (Exception) { /* déjà fermé */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// arrêt demandé
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Canal entre instances interrompu", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(NamedPipeServerStream pipe, CancellationToken token)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(IoTimeout);
|
||||
|
||||
var line = await ReadLineAsync(pipe, timeout.Token);
|
||||
string reply;
|
||||
if (line is not null && TryParseLaunch(line, out var gameId))
|
||||
{
|
||||
try
|
||||
{
|
||||
reply = _onLaunchRequest(gameId) ? ReplyAccepted : ReplyUnknownGame;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Demande de lancement transmise par une autre instance", ex);
|
||||
reply = ReplyError;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warn("Canal entre instances : requête invalide ignorée.");
|
||||
reply = ReplyError;
|
||||
}
|
||||
|
||||
await pipe.WriteAsync(Encoding.UTF8.GetBytes(reply + "\n"), timeout.Token);
|
||||
await pipe.FlushAsync(timeout.Token);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Côté nouvelle instance (client)
|
||||
|
||||
/// <summary>
|
||||
/// Transmet au launcher déjà ouvert la demande de lancement de <paramref name="gameId"/>.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Vrai si le launcher ouvert a pris la demande en charge (la nouvelle instance peut se fermer
|
||||
/// sans rien afficher) ; faux s'il n'a pas répondu (ancienne version du launcher, launcher en
|
||||
/// cours de démarrage ou de mise à jour…).
|
||||
/// </returns>
|
||||
public static async Task<bool> TryRequestLaunchAsync(string instanceName, string gameId, TimeSpan connectTimeout)
|
||||
{
|
||||
if (!IsValidGameId(gameId))
|
||||
{
|
||||
Log.Warn("Argument --game invalide : demande non transmise au launcher ouvert.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(".", BuildPipeName(instanceName), PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
using var cts = new CancellationTokenSource(connectTimeout + IoTimeout);
|
||||
await client.ConnectAsync((int)connectTimeout.TotalMilliseconds, cts.Token);
|
||||
|
||||
// Cette instance vient d'être lancée par l'utilisateur : elle peut céder le premier plan
|
||||
// au launcher déjà ouvert (sinon Windows le laisserait clignoter dans la barre des tâches).
|
||||
GrantForegroundTo(client);
|
||||
|
||||
await client.WriteAsync(Encoding.UTF8.GetBytes($"{LaunchCommand} {gameId}\n"), cts.Token);
|
||||
await client.FlushAsync(cts.Token);
|
||||
var reply = await ReadLineAsync(client, cts.Token);
|
||||
|
||||
Log.Info($"Demande de lancement de {gameId} transmise au launcher déjà ouvert (réponse : {reply ?? "aucune"}).");
|
||||
return reply is ReplyAccepted or ReplyUnknownGame;
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException
|
||||
or OperationCanceledException or InvalidOperationException)
|
||||
{
|
||||
// UnauthorizedAccessException : canal créé par un autre utilisateur (refusé par CurrentUserOnly).
|
||||
Log.Warn($"Le launcher déjà ouvert n'a pas répondu : {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protocole
|
||||
|
||||
private static async Task<string?> ReadLineAsync(PipeStream pipe, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[MaxMessageBytes];
|
||||
var count = 0;
|
||||
while (count < buffer.Length)
|
||||
{
|
||||
var read = await pipe.ReadAsync(buffer.AsMemory(count, buffer.Length - count), ct);
|
||||
if (read == 0) break;
|
||||
count += read;
|
||||
var newLine = Array.IndexOf(buffer, (byte)'\n', 0, count);
|
||||
if (newLine >= 0) return Encoding.UTF8.GetString(buffer, 0, newLine).TrimEnd('\r');
|
||||
}
|
||||
return null; // message trop long ou incomplet
|
||||
}
|
||||
|
||||
private static bool TryParseLaunch(string line, out string gameId)
|
||||
{
|
||||
gameId = "";
|
||||
var prefix = LaunchCommand + " ";
|
||||
if (!line.StartsWith(prefix, StringComparison.Ordinal)) return false;
|
||||
var candidate = line[prefix.Length..];
|
||||
if (!IsValidGameId(candidate)) return false;
|
||||
gameId = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mêmes règles que launcher.json (identifiant simple, sans espaces), plus une longueur bornée.</summary>
|
||||
private static bool IsValidGameId(string? gameId) =>
|
||||
!string.IsNullOrEmpty(gameId) &&
|
||||
gameId.Length <= MaxGameIdLength &&
|
||||
!gameId.Any(c => char.IsWhiteSpace(c) || char.IsControl(c)) &&
|
||||
gameId.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
|
||||
|
||||
/// <summary>Nom du canal : nom du verrou d'instance unique + session Windows + SID de l'utilisateur.</summary>
|
||||
private static string BuildPipeName(string instanceName)
|
||||
{
|
||||
var name = instanceName.StartsWith(@"Local\", StringComparison.OrdinalIgnoreCase) ? instanceName[6..] : instanceName;
|
||||
var safe = new string(name.Select(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '_').ToArray());
|
||||
|
||||
string user;
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
user = identity.User?.Value ?? Environment.UserName;
|
||||
|
||||
int session;
|
||||
using (var process = Process.GetCurrentProcess())
|
||||
session = process.SessionId;
|
||||
|
||||
return $"{safe}.Instance.{session}.{user}";
|
||||
}
|
||||
|
||||
private static void GrantForegroundTo(NamedPipeClientStream client)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (GetNamedPipeServerProcessId(client.SafePipeHandle, out var serverPid))
|
||||
AllowSetForegroundWindow(serverPid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Passage au premier plan du launcher ouvert : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AllowSetForegroundWindow(uint processId);
|
||||
}
|
||||
@@ -52,14 +52,46 @@ public interface IRegistryService
|
||||
{
|
||||
GameRegistration? Read();
|
||||
void Write(GameRegistration registration, long estimatedSizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Emplacement du launcher (LauncherPath, bouton « Désinstaller » de Windows) mis à jour s'il a
|
||||
/// été déplacé. Ne crée rien si le jeu n'est pas enregistré.
|
||||
/// </summary>
|
||||
void RefreshLauncherPath();
|
||||
void Delete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raccourci du Bureau qui ouvre ce jeu dans le launcher (arguments « --game <GameId> »),
|
||||
/// quel que soit son nom.
|
||||
/// </summary>
|
||||
/// <param name="FilePath">Fichier .lnk.</param>
|
||||
/// <param name="TargetPath">Exécutable visé par le raccourci.</param>
|
||||
/// <param name="PointsToThisLauncher">Le raccourci vise le launcher en cours d'exécution.</param>
|
||||
/// <param name="TargetExists">L'exécutable visé existe encore (sinon : launcher déplacé, renommé ou supprimé).</param>
|
||||
public sealed record DesktopShortcut(string FilePath, string TargetPath, bool PointsToThisLauncher, bool TargetExists)
|
||||
{
|
||||
public string Name => System.IO.Path.GetFileNameWithoutExtension(FilePath);
|
||||
}
|
||||
|
||||
public interface IShortcutService
|
||||
{
|
||||
/// <summary>Emplacement du launcher en cours d'exécution.</summary>
|
||||
string LauncherPath { get; }
|
||||
|
||||
void CreateDesktopShortcut();
|
||||
|
||||
/// <summary>Raccourci existant dont la cible a disparu (launcher renommé ou déplacé) : recréé vers le launcher actuel.</summary>
|
||||
/// <summary>
|
||||
/// Démarrage du launcher : les raccourcis de ce jeu dont la cible a disparu (launcher renommé
|
||||
/// ou déplacé) sont redirigés vers le launcher actuel, sans rien demander.
|
||||
/// </summary>
|
||||
void RefreshDesktopShortcut();
|
||||
|
||||
/// <summary>Raccourcis du Bureau (dossier de l'utilisateur) qui ouvrent ce jeu.</summary>
|
||||
IReadOnlyList<DesktopShortcut> FindDesktopShortcuts();
|
||||
|
||||
/// <summary>Redirige le raccourci vers le launcher actuel. Faux en cas d'échec (journalisé).</summary>
|
||||
bool RetargetDesktopShortcut(DesktopShortcut shortcut);
|
||||
|
||||
void RemoveDesktopShortcut();
|
||||
}
|
||||
@@ -9,91 +9,225 @@ namespace GameLauncher.Services;
|
||||
/// <summary>
|
||||
/// Raccourci bureau vers le LAUNCHER (et non vers le jeu) : ainsi les mises à jour
|
||||
/// sont toujours vérifiées avant de jouer.
|
||||
///
|
||||
/// Un raccourci « de ce jeu » se reconnaît à ses arguments (<c>--game <GameId></c>), pas à son
|
||||
/// nom : il est retrouvé même si le joueur l'a renommé. Si le launcher est déplacé ou renommé,
|
||||
/// ses raccourcis sont redirigés vers le nouvel emplacement (au démarrage si l'ancien exécutable
|
||||
/// a disparu, ou depuis « Réparer »).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ShortcutService : IShortcutService
|
||||
{
|
||||
/// <summary>Nombre maximal de raccourcis examinés sur le Bureau (borne le temps de recherche).</summary>
|
||||
private const int MaxScannedShortcuts = 300;
|
||||
|
||||
private readonly LauncherConfig _config;
|
||||
private readonly string _launcherPath;
|
||||
|
||||
public ShortcutService(LauncherConfig config, string launcherPath)
|
||||
{
|
||||
_config = config;
|
||||
_launcherPath = launcherPath;
|
||||
_launcherPath = Path.GetFullPath(launcherPath);
|
||||
}
|
||||
|
||||
private string ShortcutPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
|
||||
PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
|
||||
public string LauncherPath => _launcherPath;
|
||||
|
||||
private string Arguments => $"--game {_config.GameId}"; // ouvre le launcher sur ce jeu
|
||||
|
||||
private static string DesktopDirectory => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||
|
||||
private string ShortcutPath => Path.Combine(DesktopDirectory, PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
|
||||
|
||||
public void CreateDesktopShortcut()
|
||||
{
|
||||
object? shell = null;
|
||||
object? link = null;
|
||||
try
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("WScript.Shell")
|
||||
?? throw new InvalidOperationException("WScript.Shell indisponible.");
|
||||
shell = Activator.CreateInstance(type)!;
|
||||
dynamic dynShell = shell;
|
||||
link = dynShell.CreateShortcut(ShortcutPath);
|
||||
dynamic dynLink = link!;
|
||||
dynLink.TargetPath = _launcherPath;
|
||||
dynLink.Arguments = $"--game {_config.GameId}"; // ouvre le launcher sur ce jeu
|
||||
dynLink.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
|
||||
dynLink.IconLocation = _launcherPath + ",0";
|
||||
dynLink.Description = _config.GameName;
|
||||
dynLink.Save();
|
||||
WithShell(shell => WithLink(shell, ShortcutPath, link =>
|
||||
{
|
||||
link.TargetPath = _launcherPath;
|
||||
link.Arguments = Arguments;
|
||||
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
|
||||
link.IconLocation = _launcherPath + ",0";
|
||||
link.Description = _config.GameName;
|
||||
link.Save();
|
||||
return true;
|
||||
}));
|
||||
Log.Info($"Raccourci créé : {ShortcutPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Création du raccourci impossible", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (link is not null && Marshal.IsComObject(link)) Marshal.FinalReleaseComObject(link);
|
||||
if (shell is not null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshDesktopShortcut()
|
||||
{
|
||||
if (!File.Exists(ShortcutPath)) return;
|
||||
foreach (var shortcut in FindDesktopShortcuts())
|
||||
{
|
||||
// Raccourci valide, ou vers un autre exemplaire du launcher encore présent : on n'y touche
|
||||
// pas ici (le bouton « Réparer » propose de le rediriger, après confirmation).
|
||||
if (shortcut.PointsToThisLauncher || shortcut.TargetExists) continue;
|
||||
|
||||
Log.Info($"Raccourci « {shortcut.Name} » vers un launcher disparu ({shortcut.TargetPath}) : mise à jour vers {_launcherPath}");
|
||||
RetargetDesktopShortcut(shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<DesktopShortcut> FindDesktopShortcuts()
|
||||
{
|
||||
var found = new List<DesktopShortcut>();
|
||||
List<string> files;
|
||||
try
|
||||
{
|
||||
var desktop = DesktopDirectory;
|
||||
if (string.IsNullOrEmpty(desktop) || !Directory.Exists(desktop)) return found;
|
||||
files = Directory.EnumerateFiles(desktop, "*.lnk", SearchOption.TopDirectoryOnly)
|
||||
.Take(MaxScannedShortcuts)
|
||||
.OrderBy(file => !SamePath(file, ShortcutPath)) // celui créé par le launcher en premier
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Lecture du Bureau impossible : {ex.Message}");
|
||||
return found;
|
||||
}
|
||||
if (files.Count == 0) return found;
|
||||
|
||||
try
|
||||
{
|
||||
WithShell(shell =>
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
string? target;
|
||||
string? arguments;
|
||||
try
|
||||
{
|
||||
(target, arguments) = WithLink(shell, file, link => ((string?)link.TargetPath, (string?)link.Arguments));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Lecture du raccourci impossible ({file}) : {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target) || !IsForThisGame(arguments)) continue;
|
||||
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target)));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Recherche des raccourcis du Bureau impossible : {ex.Message}");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public bool RetargetDesktopShortcut(DesktopShortcut shortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
WithShell(shell => WithLink(shell, shortcut.FilePath, link =>
|
||||
{
|
||||
// Icône personnalisée par le joueur : conservée. Icône de l'ancien launcher : remplacée.
|
||||
var iconFile = IconFile((string?)link.IconLocation);
|
||||
var keepIcon = iconFile is not null && File.Exists(iconFile) && !SamePath(iconFile, shortcut.TargetPath);
|
||||
|
||||
link.TargetPath = _launcherPath;
|
||||
link.Arguments = Arguments;
|
||||
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
|
||||
if (!keepIcon) link.IconLocation = _launcherPath + ",0";
|
||||
link.Save();
|
||||
return true;
|
||||
}));
|
||||
Log.Info($"Raccourci « {shortcut.Name} » redirigé : {shortcut.TargetPath} → {_launcherPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Mise à jour du raccourci impossible ({shortcut.FilePath})", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
/// <summary>Arguments du raccourci contenant « --game <GameId> » (guillemets tolérés).</summary>
|
||||
private bool IsForThisGame(string? arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments)) return false;
|
||||
var tokens = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(t => t.Trim('"'))
|
||||
.ToArray();
|
||||
for (var i = 0; i < tokens.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(tokens[i], "--game", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(tokens[i + 1], _config.GameId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>« C:\…\GameLauncher.exe,0 » → « C:\…\GameLauncher.exe ».</summary>
|
||||
private static string? IconFile(string? iconLocation)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconLocation)) return null;
|
||||
var comma = iconLocation.LastIndexOf(',');
|
||||
var file = (comma > 0 ? iconLocation[..comma] : iconLocation).Trim().Trim('"');
|
||||
return file.Length == 0 ? null : Environment.ExpandEnvironmentVariables(file);
|
||||
}
|
||||
|
||||
private static bool SamePath(string? a, string? b)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
|
||||
try
|
||||
{
|
||||
return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false; // chemin invalide
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Objet COM WScript.Shell, libéré après usage.</summary>
|
||||
private static T WithShell<T>(Func<object, T> action)
|
||||
{
|
||||
object? shell = null;
|
||||
object? link = null;
|
||||
string? target;
|
||||
try
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("WScript.Shell")
|
||||
?? throw new InvalidOperationException("WScript.Shell indisponible.");
|
||||
shell = Activator.CreateInstance(type)!;
|
||||
dynamic dynShell = shell;
|
||||
link = dynShell.CreateShortcut(ShortcutPath); // ouvre le raccourci existant
|
||||
dynamic dynLink = link!;
|
||||
target = (string?)dynLink.TargetPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Lecture du raccourci impossible ({ShortcutPath}) : {ex.Message}");
|
||||
return;
|
||||
shell = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("WScript.Shell indisponible.");
|
||||
return action(shell);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (link is not null && Marshal.IsComObject(link)) Marshal.FinalReleaseComObject(link);
|
||||
if (shell is not null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
|
||||
Release(shell);
|
||||
}
|
||||
|
||||
// Raccourci valide (ou vers un autre programme encore présent) : on n'y touche pas.
|
||||
if (string.IsNullOrWhiteSpace(target) ||
|
||||
string.Equals(target, _launcherPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
File.Exists(target))
|
||||
return;
|
||||
|
||||
Log.Info($"Raccourci vers un launcher disparu ({target}) : mise à jour vers {_launcherPath}");
|
||||
CreateDesktopShortcut();
|
||||
}
|
||||
|
||||
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
|
||||
/// <summary>Ouvre (ou prépare, s'il n'existe pas) un raccourci .lnk, libéré après usage.</summary>
|
||||
private static T WithLink<T>(object shell, string path, Func<dynamic, T> action)
|
||||
{
|
||||
object? link = null;
|
||||
try
|
||||
{
|
||||
link = ((dynamic)shell).CreateShortcut(path);
|
||||
return action(link!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Release(link);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Release(object? com)
|
||||
{
|
||||
if (com is not null && Marshal.IsComObject(com)) Marshal.FinalReleaseComObject(com);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private bool _needsRepair;
|
||||
private string? _lastInstallDirectory;
|
||||
private int _bannerVersion;
|
||||
private bool _launchWhenReady;
|
||||
|
||||
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
|
||||
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
|
||||
@@ -168,6 +169,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(ShowRailBadge));
|
||||
OnPropertyChanged(nameof(RailToolTip));
|
||||
RefreshCommands();
|
||||
|
||||
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
|
||||
if (_launchWhenReady && value != LauncherState.Checking)
|
||||
{
|
||||
_launchWhenReady = false;
|
||||
_ui.Post(_ => LaunchFromShortcut(), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +742,14 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
// Raccourcis du Bureau et entrée « Applications installées » : redirigés vers ce launcher
|
||||
// s'il a été déplacé. Fait en premier : instantané, et utile même hors ligne.
|
||||
var linksRepaired = RepairLauncherLinks();
|
||||
void ReportLinksOnly()
|
||||
{
|
||||
if (linksRepaired is not null) ShowBanner("Raccourci du Bureau mis à jour", linksRepaired, DialogKind.Success);
|
||||
}
|
||||
|
||||
var manifest = await TryFetchManifestAsync();
|
||||
if (manifest is null)
|
||||
{
|
||||
@@ -741,6 +757,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_dialogs.Show("Serveur injoignable",
|
||||
"La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.",
|
||||
DialogKind.Error);
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -749,7 +766,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (!_dialogs.Confirm("Réparer le jeu",
|
||||
$"Le jeu va être téléchargé à nouveau dans :\n{dir}\n\nTaille du téléchargement : {Format.Bytes(manifest.Archive.Size)}",
|
||||
"Réparer", "Annuler", DialogKind.Question))
|
||||
{
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
var root = Path.GetPathRoot(dir);
|
||||
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
||||
@@ -757,11 +777,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_dialogs.Show("Lecteur introuvable",
|
||||
$"Le lecteur « {root} » n'est pas disponible. Branchez-le, localisez le jeu à un autre endroit ou oubliez cette installation.",
|
||||
DialogKind.Warning);
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
if (!PathHelper.TryEnsureWritable(dir, out var error))
|
||||
{
|
||||
_dialogs.Show("Réparation impossible", error!, DialogKind.Error);
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
var required = manifest.Archive.Size + manifest.TotalSize;
|
||||
@@ -771,6 +793,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_dialogs.Show("Espace disque insuffisant",
|
||||
$"{Format.Bytes(required)} sont nécessaires, mais seulement {Format.Bytes(free)} sont disponibles sur ce lecteur.",
|
||||
DialogKind.Error);
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -784,7 +807,57 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_needsRepair = false;
|
||||
});
|
||||
|
||||
if (ok) ShowBanner("Réparation terminée", "Tous les fichiers du jeu sont de nouveau intègres.", DialogKind.Success);
|
||||
if (ok)
|
||||
ShowBanner("Réparation terminée",
|
||||
linksRepaired is null
|
||||
? "Tous les fichiers du jeu sont de nouveau intègres."
|
||||
: $"Tous les fichiers du jeu sont de nouveau intègres. {linksRepaired}",
|
||||
DialogKind.Success);
|
||||
else
|
||||
ReportLinksOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie que les raccourcis du Bureau de ce jeu et l'entrée « Applications installées » de
|
||||
/// Windows désignent bien ce launcher (il a pu être déplacé ou renommé depuis leur création).
|
||||
/// • raccourci vers un exécutable disparu → redirigé sans question ;
|
||||
/// • raccourci vers un AUTRE exemplaire du launcher, encore présent → redirigé après confirmation.
|
||||
/// </summary>
|
||||
/// <returns>Message à afficher si au moins un raccourci a été mis à jour, sinon null.</returns>
|
||||
private string? RepairLauncherLinks()
|
||||
{
|
||||
try
|
||||
{
|
||||
_registry.RefreshLauncherPath();
|
||||
|
||||
var repaired = 0;
|
||||
foreach (var shortcut in _shortcuts.FindDesktopShortcuts())
|
||||
{
|
||||
if (shortcut.PointsToThisLauncher) continue;
|
||||
|
||||
if (shortcut.TargetExists &&
|
||||
!_dialogs.Confirm("Raccourci du Bureau",
|
||||
$"Le raccourci « {shortcut.Name} » ouvre un autre exemplaire du launcher :\n{shortcut.TargetPath}\n\n" +
|
||||
$"Le rediriger vers ce launcher ?\n{_shortcuts.LauncherPath}",
|
||||
"Rediriger", "Ne pas modifier", DialogKind.Question))
|
||||
continue;
|
||||
|
||||
if (_shortcuts.RetargetDesktopShortcut(shortcut)) repaired++;
|
||||
}
|
||||
|
||||
return repaired switch
|
||||
{
|
||||
0 => null,
|
||||
1 => "Le raccourci du Bureau a été mis à jour vers l'emplacement actuel du launcher.",
|
||||
_ => $"{repaired} raccourcis du Bureau ont été mis à jour vers l'emplacement actuel du launcher."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non bloquant : la réparation des fichiers du jeu continue.
|
||||
Log.Error("Vérification des raccourcis du Bureau", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task VerifyIntegrityAsync()
|
||||
@@ -988,6 +1061,45 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
// ------------------------------------------------------------------ Lancement du jeu
|
||||
|
||||
/// <summary>
|
||||
/// Demande de lancement transmise par un raccourci du Bureau alors que le launcher était déjà
|
||||
/// ouvert : le jeu est lancé s'il est prêt (et pas déjà en cours d'exécution), sinon le bandeau
|
||||
/// explique pourquoi.
|
||||
/// </summary>
|
||||
public void LaunchFromShortcut()
|
||||
{
|
||||
_launchWhenReady = false;
|
||||
switch (State)
|
||||
{
|
||||
case LauncherState.Ready:
|
||||
Play();
|
||||
break;
|
||||
case LauncherState.Checking:
|
||||
_launchWhenReady = true; // lancé dès la fin de la vérification, si le jeu est prêt
|
||||
break;
|
||||
case LauncherState.Running:
|
||||
ShowBanner("Le jeu est déjà lancé", $"{GameName} est déjà en cours d'exécution.", DialogKind.Info);
|
||||
break;
|
||||
case LauncherState.Busy:
|
||||
ShowBanner("Opération en cours", $"{GameName} pourra être lancé à la fin de l'opération en cours.", DialogKind.Info);
|
||||
break;
|
||||
case LauncherState.UpdateAvailable:
|
||||
ShowBanner("Mise à jour disponible",
|
||||
$"Une nouvelle version de {GameName} est disponible : cliquez sur « Mettre à jour » avant de jouer.",
|
||||
DialogKind.Info, autoHide: false);
|
||||
break;
|
||||
case LauncherState.NotInstalled:
|
||||
ShowBanner("Jeu non installé", $"{GameName} n'est pas installé : cliquez sur « Installer ».", DialogKind.Info);
|
||||
break;
|
||||
case LauncherState.Broken:
|
||||
case LauncherState.NeedsRepair:
|
||||
ShowBanner("Réparation nécessaire",
|
||||
$"{GameName} doit être réparé avant de pouvoir être lancé : cliquez sur « Réparer ».",
|
||||
DialogKind.Warning, autoHide: false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void Play()
|
||||
{
|
||||
var dir = InstallPath;
|
||||
@@ -999,6 +1111,14 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Jeu déjà ouvert en dehors de ce launcher (lancé directement, ou avant un redémarrage du launcher).
|
||||
if (IsAlreadyRunning(exePath))
|
||||
{
|
||||
Log.Info($"Lancement ignoré : {exePath} est déjà en cours d'exécution.");
|
||||
ShowBanner("Le jeu est déjà lancé", $"{GameName} est déjà en cours d'exécution.", DialogKind.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var arguments = string.Join(" ", new[] { _manifest?.LaunchArguments, _config.GameArguments }
|
||||
@@ -1038,6 +1158,56 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si l'exécutable du jeu tourne déjà dans la session Windows courante. Si le chemin d'un
|
||||
/// processus de même nom ne peut pas être lu (ex. jeu lancé en administrateur), il est considéré
|
||||
/// comme étant le jeu : mieux vaut ne pas le lancer deux fois.
|
||||
/// </summary>
|
||||
private static bool IsAlreadyRunning(string exePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(exePath);
|
||||
int session;
|
||||
using (var current = Process.GetCurrentProcess()) session = current.SessionId;
|
||||
|
||||
var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(fullPath));
|
||||
try
|
||||
{
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (process.SessionId != session || process.HasExited) continue;
|
||||
var path = process.MainModule?.FileName;
|
||||
if (path is null || string.Equals(Path.GetFullPath(path), fullPath, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException
|
||||
or NotSupportedException)
|
||||
{
|
||||
if (!SafeHasExited(process)) return true; // accès refusé : même nom, même session
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var process in processes) process.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Recherche du processus du jeu impossible : {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SafeHasExited(Process process)
|
||||
{
|
||||
try { return process.HasExited; }
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private void OnGameExited(object? sender, EventArgs e)
|
||||
{
|
||||
_ui.Post(_ =>
|
||||
|
||||
@@ -115,8 +115,10 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
/// <summary>
|
||||
/// Initialise tous les jeux (détection + mises à jour).
|
||||
/// <paramref name="uninstallGameId"/> : lancé depuis « Applications installées » de Windows.
|
||||
/// <paramref name="launchGameId"/> : lancé depuis un raccourci du Bureau ; ce jeu est lancé dès que
|
||||
/// sa vérification (et son éventuelle mise à jour automatique) est terminée, s'il est prêt.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync(bool uninstall, string? uninstallGameId)
|
||||
public async Task InitializeAsync(bool uninstall, string? uninstallGameId, string? launchGameId = null)
|
||||
{
|
||||
var pending = Games.ToList();
|
||||
|
||||
@@ -140,14 +142,27 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(pending.Select(g => SafeInitializeAsync(g, uninstall: false)));
|
||||
var launchTarget = uninstall ? null : Find(launchGameId);
|
||||
if (!uninstall && launchGameId is not null && launchTarget is null)
|
||||
Log.Warn($"Raccourci vers un jeu inconnu de ce launcher : {launchGameId}");
|
||||
|
||||
await Task.WhenAll(pending.Select(g => SafeInitializeAsync(g, uninstall: false,
|
||||
launchWhenDone: ReferenceEquals(g, launchTarget))));
|
||||
}
|
||||
|
||||
private static async Task SafeInitializeAsync(GameViewModel game, bool uninstall)
|
||||
private async Task SafeInitializeAsync(GameViewModel game, bool uninstall, bool launchWhenDone = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
await game.InitializeAsync(uninstall);
|
||||
|
||||
// Raccourci du Bureau : le jeu est lancé une fois à jour (réglages « Réduire » / « Fermer »
|
||||
// le launcher respectés par le lancement lui-même).
|
||||
if (launchWhenDone && !IsExitConfirmed)
|
||||
{
|
||||
Log.Info($"[{game.GameId}] Lancement demandé par le raccourci du Bureau.");
|
||||
game.LaunchFromShortcut();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -155,6 +170,24 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raccourci du Bureau (--game) ouvert alors que le launcher l'était déjà : la fenêtre revient
|
||||
/// au premier plan sur ce jeu, qui est lancé s'il est prêt.
|
||||
/// </summary>
|
||||
public void LaunchFromShortcut(string gameId)
|
||||
{
|
||||
_window.Restore();
|
||||
var game = Find(gameId);
|
||||
if (game is null)
|
||||
{
|
||||
SelectedGame?.ShowNotice("Jeu inconnu",
|
||||
$"Le raccourci demande le jeu « {gameId} », qui n'est pas proposé par ce launcher.", DialogKind.Warning);
|
||||
return;
|
||||
}
|
||||
SelectedGame = game;
|
||||
game.LaunchFromShortcut();
|
||||
}
|
||||
|
||||
/// <summary>Vrai si un AUTRE jeu que <paramref name="except"/> a une opération en cours.</summary>
|
||||
public bool IsAnyOtherGameBusy(GameViewModel? except) =>
|
||||
Games.Any(g => !ReferenceEquals(g, except) && g.IsBusy);
|
||||
|
||||
+37
-41
@@ -1,54 +1,50 @@
|
||||
# Launcher 1.1.3
|
||||
# Launcher 1.2.0
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Mise à jour du 18 octobre 2026
|
||||
Mise à jour du 24 octobre 2026
|
||||
==================================================================
|
||||
|
||||
Cette version réorganise les paramètres, enrichit les notes de version et corrige le nom du launcher après une mise à jour automatique.
|
||||
Cette version permet de lancer le jeu directement depuis son icône du Bureau, même quand le launcher est déjà ouvert, et répare les raccourcis après un déplacement du launcher.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PARAMÈTRES
|
||||
LANCEMENT DEPUIS LE BUREAU
|
||||
------------------------------------------------------------------
|
||||
Les paramètres sont maintenant répartis dans deux fenêtres distinctes :
|
||||
|
||||
- NOUVEAU : Paramètres du launcher, depuis la roue crantée en haut à
|
||||
droite. Ces réglages sont communs à tous les jeux :
|
||||
* mise à jour automatique du launcher et bouton « Vérifier les
|
||||
mises à jour » ;
|
||||
* réduire ou fermer le launcher pendant la partie ;
|
||||
* accès au dossier des journaux.
|
||||
- NOUVEAU : Paramètres du jeu, depuis le menu « … ». Ces réglages ne
|
||||
concernent que le jeu sélectionné :
|
||||
* mises à jour automatiques du jeu ;
|
||||
* téléchargements simultanés et vérification des fichiers après
|
||||
l'installation ;
|
||||
* informations : version installée, dernière version, taille et
|
||||
dossier du jeu.
|
||||
- Vos réglages « Lancement du jeu » sont repris automatiquement au
|
||||
premier démarrage de cette version.
|
||||
- NOUVEAU : l'icône du jeu sur le Bureau lance directement le jeu.
|
||||
Le launcher s'ouvre, vérifie les mises à jour, puis démarre la
|
||||
partie :
|
||||
* vos réglages « Réduire le launcher pendant la partie » et
|
||||
« Fermer le launcher au lancement du jeu » sont respectés ;
|
||||
* si la mise à jour automatique du jeu est activée, la nouvelle
|
||||
version est installée avant de jouer ;
|
||||
* sans connexion Internet, la version installée est lancée.
|
||||
Si le jeu ne peut pas être lancé (mise à jour à installer,
|
||||
réparation nécessaire...), le launcher reste ouvert et vous
|
||||
explique pourquoi.
|
||||
- NOUVEAU : launcher déjà ouvert ? L'icône du Bureau n'affiche plus
|
||||
le message « Le launcher est déjà ouvert » : le launcher revient
|
||||
au premier plan sur le bon jeu et le lance.
|
||||
- Ouvert sans passer par l'icône du jeu, le launcher signale
|
||||
toujours qu'il est déjà ouvert.
|
||||
- Le jeu n'est plus lancé une seconde fois s'il est déjà en cours
|
||||
d'exécution, même s'il a été démarré sans le launcher.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
NOTES DE VERSION
|
||||
LAUNCHER DÉPLACÉ ET RÉPARATION
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : notes de version plus lisibles, avec titre, sous-titre,
|
||||
introduction, sections, commentaires, sous-points et étiquettes.
|
||||
- NOUVEAU : bouton « Agrandir » pour lire les notes de version en
|
||||
grand.
|
||||
- NOUVEAU : les nouveautés du launcher utilisent la même présentation.
|
||||
La fenêtre de mise à jour affiche les notes de toutes les versions
|
||||
publiées depuis la vôtre.
|
||||
- Les notes reçues du serveur sont limitées en taille et nettoyées
|
||||
avant d'être affichées.
|
||||
Vous avez déplacé ou renommé GameLauncher.exe ? Les raccourcis
|
||||
suivent :
|
||||
|
||||
- Au démarrage, les raccourcis du Bureau qui pointaient vers
|
||||
l'ancien emplacement sont corrigés automatiquement, même si vous
|
||||
les avez renommés.
|
||||
- NOUVEAU : le bouton « Réparer » vérifie aussi les raccourcis du
|
||||
Bureau et le bouton « Désinstaller » de Windows, et les fait
|
||||
pointer vers l'emplacement actuel du launcher. Cette vérification
|
||||
fonctionne même sans connexion Internet.
|
||||
* si un raccourci ouvre une autre copie du launcher, encore
|
||||
présente, le launcher vous demande avant de le modifier ;
|
||||
* l'icône que vous avez personnalisée est conservée.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
MISE À JOUR DU LAUNCHER
|
||||
------------------------------------------------------------------
|
||||
- Après une mise à jour automatique, le launcher s'appelle toujours
|
||||
GameLauncher.exe, même si le fichier avait été renommé (par exemple
|
||||
GameLauncher-1.1.2.exe, tel que publié sur le serveur).
|
||||
- Le raccourci du Bureau est réparé s'il pointait vers un launcher
|
||||
renommé ou déplacé.
|
||||
|
||||
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
|
||||
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation. La communication entre l'icône du Bureau et le launcher déjà ouvert reste limitée à votre session Windows.
|
||||
+57
-134
@@ -1,155 +1,78 @@
|
||||
# Version 1.2
|
||||
# Version 1.5
|
||||
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
|
||||
Niveau 1 « L'école » - Mise à jour du 17 octobre 2026
|
||||
Niveau 1 « L'école » - Mise à jour du 24 octobre 2026
|
||||
==================================================================
|
||||
|
||||
Merci aux bêta-testeurs ! Cette mise à jour corrige les problèmes remontés pendant la bêta du niveau 1 et ajoute plusieurs améliorations.
|
||||
Cette mise à jour branche une grande partie de l'école sur le groupe électrogène et son armoire de distribution, rend les objets bien plus faciles à ramasser et corrige plusieurs problèmes de lumière, de traduction et de fin de partie.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PROGRESSION : PROBLÈMES BLOQUANTS CORRIGÉS
|
||||
COURANT ET GROUPE ELECTROGENE
|
||||
------------------------------------------------------------------
|
||||
- La petite clé obtenue en résolvant le labyrinthe de Léa peut
|
||||
maintenant être ramassée.
|
||||
- Le cadenas du casier de Léa s'ouvre bien avec sa clé.
|
||||
- Le coffre du gymnase réagit enfin : il se crochète avec les épingles
|
||||
à cheveux.
|
||||
- Le fusible n°2, introuvable jusqu'ici, se trouve maintenant dans ce
|
||||
coffre.
|
||||
- Le quatrième interrupteur de l'armoire de distribution n'est plus
|
||||
caché dans une colonne.
|
||||
- Le mot de Léa, dans la bibliothèque, n'est plus coincé dans une
|
||||
chaise : il est posé à plat sur la table, bien visible.
|
||||
- NOUVEAU : chaque circuit de l'armoire de distribution compte.
|
||||
* levier ÉCOLE baissé : toute l'école perd le courant (néons,
|
||||
télé, magnétoscope, vidéosurveillance, gymnase...), l'extérieur
|
||||
reste éclairé ;
|
||||
* levier EXTÉRIEUR baissé : seul le projecteur de chantier
|
||||
s'éteint, l'école reste éclairée.
|
||||
Relevez le levier, groupe en marche, et le courant revient.
|
||||
- Les leviers de l'armoire ne se bloquent plus après le premier
|
||||
démarrage du groupe : on peut encore provoquer une surcharge. Le
|
||||
voyant de l'armoire s'éteint dès que le réglage n'est plus bon.
|
||||
- Le lecteur de carte de la porte du toit ne fonctionne plus sans
|
||||
courant. Une porte déjà ouverte avec la carte reste ouverte après
|
||||
une coupure.
|
||||
- L'étiquette du groupe électrogène a changé de place : elle est
|
||||
maintenant rangée dans le local technique.
|
||||
- Les messages de coupure et de retour du courant ne donnent plus la
|
||||
solution : à vous de trouver d'où vient la panne.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
ÉNIGMES
|
||||
LUMIERE
|
||||
------------------------------------------------------------------
|
||||
- Labyrinthe à bille : il est maintenant posé sur le bureau (et non
|
||||
plus sur la chaise), et ses commandes ont été corrigées. Elles ne
|
||||
s'inversent plus selon l'inclinaison et la bille ne « flotte » plus.
|
||||
- Labyrinthe : un message d'aide explique les commandes la première
|
||||
fois, ou indique qu'il manque la bille.
|
||||
- Planches clouées : une aide indique, la première fois, qu'il faut
|
||||
bouger la souris pour les arracher. Une fois arrachées, les planches
|
||||
tombent au sol au lieu de rester en l'air.
|
||||
- NOUVEAU : néons au plafond dans la remise du concierge, le local
|
||||
technique et la grande salle du couloir ouest. Ils s'allument avec
|
||||
le courant et s'éteignent avec lui, comme les autres.
|
||||
- Les lampes cassées ou tombées au sol (gymnase, couloir vers le
|
||||
gymnase, bibliothèque) restent éteintes quand le courant revient.
|
||||
- Lanterne : la forme de sa cage n'est plus projetée tout autour de
|
||||
vous ; elle produit maintenant de vraies ombres.
|
||||
- Lanterne : un plein dure 3 minutes au lieu de 60 secondes.
|
||||
* elle ne consomme que lorsqu'elle est en main ;
|
||||
* elle se recharge avec du pétrole (Tab, pétrole, « Combiner »
|
||||
avec la lanterne), pas avec les jerricans.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
OBJETS MIEUX PLACÉS ET À LA BONNE TAILLE
|
||||
OBJETS ET FOUILLE
|
||||
------------------------------------------------------------------
|
||||
Plusieurs objets étaient debout, flottaient, traversaient le décor ou
|
||||
étaient beaucoup trop gros. Ils sont maintenant posés correctement :
|
||||
|
||||
- Mot de la directrice et registre : à plat sur les bureaux.
|
||||
- Clé du local technique : à plat sur un bureau.
|
||||
- Cassettes vidéo (Kermesse, Noël, EPS, CM1) : à la bonne taille et
|
||||
posées à plat.
|
||||
- Une cassette vide nommée « {name} », visible à côté du magnétoscope,
|
||||
n'apparaît plus.
|
||||
- Trousse de secours de la salle des profs : à la bonne taille, elle ne
|
||||
chevauche plus le registre.
|
||||
- Coffre-fort à molette : il ne flotte plus en l'air.
|
||||
- Livres qui volaient dans la salle des profs : retirés.
|
||||
- Carte d'accès rouge et pistolet : posés à plat.
|
||||
- Épingles à cheveux : au fond du lavabo, sans le traverser.
|
||||
- Bille de Léa : posée au sol à côté des toilettes au lieu de flotter.
|
||||
- Radio et détecteur EMF : remis à l'endroit sur le banc.
|
||||
- Thermomètre : couché sur le côté.
|
||||
- Caisse à outils du concierge : elle n'est plus à moitié dans le mur.
|
||||
- Étiquette de distribution : collée à plat sur le groupe électrogène.
|
||||
- Échelle de la cour : elle s'arrête au niveau des tuiles du toit.
|
||||
- Projecteur de la cour : remis debout et orienté vers le groupe
|
||||
électrogène.
|
||||
- Sac à dos de la bibliothèque : plus petit et posé correctement.
|
||||
- Trousses de soins, sacs à dos et jerricans trouvés dans les caisses :
|
||||
à une taille réaliste.
|
||||
- Il n'est plus nécessaire d'être tout près ou accroupi pour prendre
|
||||
un objet posé au sol ou sur un meuble bas : la portée passe de
|
||||
1,5 à 2,5 mètres. Les petits objets (clés, carte, cassettes,
|
||||
munitions, fusibles, bille) sont aussi plus faciles à viser.
|
||||
- Caisses en bois : l'objet qui en sort n'est plus caché par les
|
||||
débris, il se voit et se ramasse tout de suite. La première fois,
|
||||
un message vous prévient qu'un objet est tombé.
|
||||
- Un bidon de pétrole remplace une pile dans un casier du couloir
|
||||
ouest : le niveau compte maintenant 3 bidons pour la lanterne.
|
||||
- La vraie cassette « CM1 - Mme Roussel (12/10) » a changé de place :
|
||||
elle est maintenant sur une étagère de la bibliothèque.
|
||||
- La pile cachée dans les toilettes est n'est plus enfoncée dans la
|
||||
cuvette.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
GROUPE ÉLECTROGÈNE ET COURANT
|
||||
CORRECTIONS
|
||||
------------------------------------------------------------------
|
||||
- Après avoir rempli le groupe électrogène, il n'affiche plus à tort
|
||||
qu'il est vide.
|
||||
- Sans carburant, l'interrupteur de démarrage n'affiche plus
|
||||
« Allumer / Éteindre » comme si le groupe tournait.
|
||||
- Le courant ne revient que si le groupe électrogène tourne ET que
|
||||
les bons leviers sont en place.
|
||||
- NOUVEAU : surcharge. Si un mauvais levier est levé pendant que le
|
||||
groupe tourne, celui-ci s'arrête. Il faut corriger les leviers puis
|
||||
le redémarrer à la main.
|
||||
- NOUVEAU : consommation de carburant. Un jerrican dure environ
|
||||
20 minutes. À la panne sèche, les lumières s'éteignent :
|
||||
* les appareils que vous avez déjà utilisés (télé, magnétoscope,
|
||||
vidéosurveillance) restent utilisables ;
|
||||
* les autres restent bloqués jusqu'au retour du courant.
|
||||
Trouvez un autre jerrican pour relancer le groupe.
|
||||
- NOUVEAU : deux jerricans supplémentaires à trouver dans l'école et
|
||||
ses alentours.
|
||||
- La fiche du concierge, dans la remise, affiche bien son titre et
|
||||
son texte. Avant, elle prenait le nom du dernier objet regardé et
|
||||
restait vide à la lecture.
|
||||
- Lampe torche en main : l'aide « UV Flashlight » est traduite
|
||||
(« Lampe UV »).
|
||||
- Fin du niveau : au retour au menu principal, la souris réapparaît
|
||||
et les menus fonctionnent de nouveau. Avant, il fallait fermer le
|
||||
jeu de force.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CASSETTES VIDÉO, CAMÉSCOPE ET VIDÉOSURVEILLANCE
|
||||
------------------------------------------------------------------
|
||||
- Les cassettes ne se dupliquent plus quand on les récupère dans le
|
||||
magnétoscope, et elles n'écrasent plus celles de l'inventaire.
|
||||
- Le son des cassettes ne s'entend plus à l'autre bout de l'école.
|
||||
- La date et l'heure en bas de l'image des cassettes ne sont plus
|
||||
coupées par le bord de la télé.
|
||||
- Le sac du caméscope de Mme Roussel est maintenant visible dans le
|
||||
gymnase.
|
||||
- Batterie du caméscope : une aide explique qu'il faut avoir le
|
||||
caméscope en main pour la combiner.
|
||||
- NOUVEAU : les commandes du caméscope s'affichent à l'écran
|
||||
(zoom : clic droit ; vision nocturne : touche F).
|
||||
- NOUVEAU : les commandes de la vidéosurveillance s'affichent
|
||||
(changer de caméra, orienter la caméra, quitter).
|
||||
- NOUVEAU : trois caméras de surveillance en plus : gymnase, couloir
|
||||
principal et entrée de l'école.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
TEXTES ET OBJECTIFS
|
||||
------------------------------------------------------------------
|
||||
- L'objectif « Trouver le code de la salle des professeurs » se valide
|
||||
dès la lecture du mot qui donne le code.
|
||||
- Les deux coffres de la salle des profs affichent leur vrai nom au
|
||||
lieu de « Door ».
|
||||
- Le tableau électrique et le boîtier électrique de la bibliothèque
|
||||
affichent leur vrai nom au lieu de « Trappe ».
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉCOR, ÉCLAIRAGE ET LIMITES DU NIVEAU
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : une clôture entoure maintenant l'école et la rue
|
||||
d'arrivée. On ne peut plus quitter la zone de jeu et se perdre dans
|
||||
la campagne.
|
||||
- Toit de la cour de service : les murs invisibles sont remplacés par
|
||||
une petite grille à piques. Depuis le haut de l'échelle, on ne peut
|
||||
plus partir sur les tuiles et tomber hors de la cour.
|
||||
- La silhouette du couloir ouest est enfin visible : elle se détache
|
||||
dans le noir au bout du couloir. Avant, elle était cachée dans un mur
|
||||
et pouvait disparaître sans qu'on la voie, à travers une porte fermée.
|
||||
- Une sphère bleue qui flottait au bout de ce couloir a été retirée.
|
||||
- La porte des toilettes ouest ne fait plus un petit mouvement de
|
||||
fermeture avant de s'ouvrir, et le portillon du grillage non plus.
|
||||
- Éclairage du gymnase : les lampes suspendues éclairent maintenant
|
||||
le sol, et plus seulement le plafond. Les lampes cassées restent
|
||||
éteintes.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
STABILITÉ
|
||||
------------------------------------------------------------------
|
||||
- Correction d'une erreur au chargement d'une sauvegarde.
|
||||
- Tyrolienne : on ne subit plus de dégâts de chute à l'arrivée dans la
|
||||
rue, et on ne peut plus mourir pendant la scène de fin. Sauter de la
|
||||
tyrolienne en cours de route fait toujours des dégâts.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CONNU, EN COURS
|
||||
------------------------------------------------------------------
|
||||
- La visée du pistolet fait un simple zoom au lieu de viser par le
|
||||
viseur. Une amélioration est prévue.
|
||||
Vos sauvegardes de la version 1.4.3 restent compatibles.
|
||||
@@ -20,6 +20,6 @@ dotnet run --project "%~dp0ManifestBuilder\ManifestBuilder.csproj" -c Release --
|
||||
--exe "Exovival Zombie Reborn.exe" ^
|
||||
--game-id "EZR" ^
|
||||
--notes "%~dp0notes.txt" ^
|
||||
--exclude "*.pdb;*.log"
|
||||
|
||||
--exclude "*.pdb;*.log" ^
|
||||
--prune-objects
|
||||
pause
|
||||
Reference in new issue
Block a user