/** 
  * Initialisation d'un écran RacingGame
  *  - récupère le container créé par le HTML,
  *  - initialise les paramètres globaux de l'application
  */
 function RaceEngine(application, container) 
 {
	this.application = application;
	this.container = container;
	this.imageFx = new ImageFx();

	// paramètres liés à l'application elle-même
	this.screenHeight = 240;
	this.screenWidth = 480;
	this.defaultHorizon = 80; // quand la camera est horizontale, en lignes depuis le bas
	this.maxRoadSize = 260; // taille maximum de la route, en index d'image (demi-largeur de la chaussée)
	
	this.lineCount = 200; // nombre de lignes raster maxi de la route (au moins 2/3 de la hauteur de l'écran de jeu)
	this.roadLines = new Array(this.lineCount);
	this.objectCount = 80; // nombre maximum d'objets du décor affichés simultanément, correspondant au cutoff pour le niveau détail le plus élevé
	this.scenerySprites = new Array(this.objectCount);
	this.weatherPlaneCount = 6; // nombre de plans de profondeur pour les aléas climatiques (brouillard, pluie, neige ...)
	this.weatherPlane = new Array(this.weatherPlaneCount);
	this.tunnelSpriteCount = 10;
	this.tunnelSprites = new Array(this.tunnelSpriteCount);
	this.opponentCount = 4;
	this.opponentData = new Array(this.opponentCount);
	this.carSprites = new Array(this.opponentCount);

	this.baseDirectory = '';
	
	// paramètres de simulation, invariants
	this.distanceStep = 500; // longueur d'un segment de route (500cm = 5m)
	this.speedLossHittingScenery = 0.3; // 30% de la vitesse perdue en cas de collision avec le décor
	this.missedDoorTimePenalty = 1000; // 10s de perdues en ratant une porte

	this.skipOddFrames = false; 
	
	this.soundAvailable = true;
	this.audioContext = new Audio();
	if (this.audioContext && this.audioContext.canPlayType && "" != this.audioContext.canPlayType('audio/wav')) {
		
		// premier essai de son moteur
		// testé sur http://entropedia.co.uk/generative_music_1.2_beta/
		// ((t*(1+t/50000))&255)
		this.audioContext.src= 'common/synthEngine.wav';
		this.audioContext.load();
	} else {
		this.soundAvailable = false;
	}
	
	this.soundOn = false;

 }
 

 
 RaceEngine.prototype = {
	
	/**
	 * Définit les paramètres liés à une partie :
	 *  - mode de jeu (arcade, drift ..)
	 *  - choix de la voiture
	 */
	setupGameParameters : function(controlsType, carIndex, chosenCar, raceResult)
	{
		this.controlsType = controlsType; // simu = 1, arcade = 2, drift = 3

		// on utilise des pointeurs sur fonction pour les actions dont le comportement 
		// varie selon le mode de jeu (simu, arcade, drift). Ces actions sont le contrôle de
		// la voiture, et la télémétrie.
		// C'est une implémentation inhabituelle du pattern Stratégie, mais plus adaptée à javascript
		// que la création de classes de stratégie
		var controlFunction = [this.moveSimulation, this.moveArcade, this.moveDrift];
		var recordFunction = [this.recordRaceSimulation, this.recordRaceArcade, this.recordRaceDrift];
		if (controlsType >= 1 && controlsType <= 3)
		{
			this.moveModeSpecific = controlFunction[controlsType-1];
			this.recordModeSpecific = recordFunction[controlsType-1];
		}
		
		this.playerCar = chosenCar;
		this.playerCar.index = carIndex; // 1-based
		this.carPixelWidth = this.playerCar.pixelWidth;
		this.carLength = this.playerCar.length;
		this.automaticDrive = true;
		this.skidAccel = chosenCar.skidFactor*1.2;
		this.skidStatic = chosenCar.skidFactor;
		this.skidBrake = chosenCar.skidFactor*0.8;
		this.playerSprite.setImageFile(this.baseDirectory+this.playerCar.imageFile);
		this.raceResult = raceResult;
	},
	 
	/**
	 * Règle le niveau de détails, appelé à la création de l'environnement de jeu et inchangé ensuite.
	 * Attention, doit être appelé avant initializeEnvironment() qui crée les <div> d'affichage du décor.
	 */
	setLevelOfDetails : function(detailLevel, skipFrames) {
		// nombre d'objets du décor affichés simultanément, selon la valeur de la profondeur (0 = faible, 1 = moyen, 2 = élevé)
		// Toujours un multiple du nombre d'objets par ligne (2 actuellement)
		// Attention, si on l'augmente, ne pas dépasser l'horizon de clipping de la route 
		//(actuellement 20.000 => 40 segments, *2 côtés = 80 objets), sinon ils ne sont pas
		// réutilisés de suite et du coup ne s'effacent pas.
		var cutoffByDepth = [30, 50, 80];
		this.objectCount = cutoffByDepth[detailLevel];
		this.skipOddFrames = skipFrames;
	},
	
	/**
	 * Coupe le son sans modifier la variable this.soundOn
	 * Utilisé pour les interruptions de course (pause, menu de course, fin de partie)
	 */
	pauseSound : function()
	{
		this.audioContext.pause();
	},
	
	/**
	 * Active ou désactive le son (sauf si this.soundAvailable est à false)
	 */
	toggleSound : function(soundToggle)
	{
		this.soundOn = soundToggle && this.soundAvailable;
		if (this.soundOn) {
			this.audioContext.play();
		} else {
			this.audioContext.pause();
		}
	},
	
	/**
	 * Définit / réinitialise les paramètres liés à une course
	 */
	setupRaceParameters : function() 
	{
		this.playerZ = 1000; // abscisse curviligne du joueur sur la route
		this.formerPlayerZ = this.playerZ; // on garde la position précédente
		this.playerX = 0; // position de la caméra (et la voiture) sur la route. En unité de largeur de route (1 = bord droit de la route soit 218cm) 
		this.roadHeading = 0; // angle de la route à la hauteur du joueur (en angle route, 1u  = 5°)
		this.playerSpeed = 0; // en m/s
		this.lateralSpeed = 0; // vitesse de projection latérale après un choc
		this.playerAngle = 0; // orientation de la voiture du joueur par rapport à la route (1 unité = 1 image du sprite = 5°)
		this.engineRPM = 800; // vitesse de rotation du moteur (au ralenti par défaut)
		this.roadSectionIndex = 0; // index de la section en cours (de road.groundSection)
		this.weatherAreaIndex = 0; // index de la zone météo (de road.weatherArea, sans lien avec l'index de section au sol)
		this.currentGear = 0;
		this.raceStarted = false;
		this.raceEnded = false;
		this.raceTime = -400; // temps écoulé depuis le départ, en 1/100s. On démarre 4s avant.
		this.secondTimerDelta = 0;	// zéro du second timer, lorsqu'il est utilisé (exprimé par rapport au timer principal)
		this.secondTimerInUse = false; // utilisation du infoPanelBonusText pour le second timer
		this.bonusCountInUse = false; // utilisation du infoPanelBonusText pour compter les objets bonus
		this.bonusTextColorReset = 0; // décompte de changement de couleur du second timer (change au passage des portes, rouge si raté, vert si réussi)
		this.bonusPointsTarget = 0; // nombre de bonus à atteindre
		this.bonusPointsObtained = 0; // nombre de bonus récupérés
		this.doorMissed = false;
		this.doorCrossed = false;
		this.recording = ""; // télémétrie de la course, pour les ghosts
		this.recordedZ = this.playerZ; // intégrale de la vitesse enregistrée (on n'enregistre pas la position, juste la vitesse)
		this.recordedX = this.playerX; // intégrale de la vitesse latérale enregistrée (idem dans la coordonnée X)
		this.recordedAngle = this.playerAngle; // idem sur l'angle de rotation

		// specificités du mode drift
		this.angularSpeed = 0;  // vitesse angulaire de la voiture (en angle route par frame)
		this.speedDirection = 0; // direction du vecteur vitesse (en radians)
		this.playerHeading = 0; // angle de la voiture par rapport au zéro (en angle route, 1u  = 5°)
		this.weightOffsetX = 0; // transfert de masse latéral (en virage.) Négatif = gauche, positif = droite
		this.weightOffsetY = 0; // transfert de masse longitudinal (en accelération ou freinage). Négatif = arrière, positif = avant
		this.frontLeftSkid = false;
		this.frontRightSkid = false;
		this.rearLeftSkid = false;
		this.rearRightSkid = false;

		// initialisation des données de l'affichage "ghost", qui sera exploité
		// soit pour un rejeu, soit pour une course contre le ghost
		this.ghostRecord = '';
		this.ghostInUse = false;
		this.ghostZ = this.playerZ;
		this.ghostX = this.playerX;
		this.ghostSpeed = 0;
		this.ghostAngle = 0;
		this.ghostLateralSpeed = 0; // utilisé uniquement pour le calcul de this.speedDirection en mode replay

		this.ghostInstrumentationMinDX = 0;
		this.ghostInstrumentationMaxDX = 0;
		this.ghostInstrumentationMinDA = 0;
		this.ghostInstrumentationMaxDA = 0;
		
		this.camera = {
			focal : 600,
			posX: this.playerX,
			posY: 130,
			posZ: this.playerZ - 1100 };
		// Angle de vue, modifiable en cours de jeu (F1, F2, ..)
		// Par défaut, en mode simu, la caméra suit l'angle de la voiture.
		// En mode drift, elle suit le vecteur vitesse
		// En arcade elle suit la route.
		var cameraByMode = [0, 1, 0, 2];
		this.cameraView = cameraByMode[this.controlsType]; 
		this.customCameraAngle = 0; // mode 3, caméra pilotée par le joueur
			
		this.controlGas = false;
		this.controlBrake = false;
		this.controlLeft = false;
		this.controlRight = false;
		this.offRoad = false; // true si on roule hors de la route
		this.exitDemo = false;
		this.pause = false;
		this.raceEscaped = false;
		
		this.fpsMeasure = [ 0, 0, 0, 0, 0];
		this.frameCounter = 0;
		this.pauseFrameCounter = 0;
		
		// on remet la voiture au milieu, lors de la course précédente elle a pu bouger
		this.playerSprite.setZoom(1.0);
		this.playerCarZIndex = -6;
		this.playerSprite.setPosition((this.screenWidth-this.playerSprite.width)/2, this.screenHeight-64, this.playerCarZIndex);
		
		// création des adversaires
		var step = 0.04 * this.road.roadLength * this.distanceStep / this.opponentCount;
		if (step>5000) { step = 5000; }
		for (var index = 0; index<this.opponentCount; ++index)
		{
			this.opponentData[index] = { 
				x:0.75-0.5*(index&3), 
				z:step*index, 
				speed:0, 
				w:120,
				h:64, 
				collisionWidth:56,
				targetSpeed:20,
				targetX:0.75-0.5*(index&3),
				angle:0	};				
		}
		
		this.runnableId = -1;
		
		// lignes raster représentant la route : image et taille
		var heightString = this.road.phaseCount+"px";
		for (var i=0; i<this.lineCount; ++i) {
			this.roadLines[i].firstChild.setAttribute("src", this.road.folder+"roadline_g.png");
			this.roadLines[i].firstChild.style.height=heightString;
		}
	},
	
	/**
	 * Création des objets DHTML relatifs à l'affichage de l'environnement pseudo-3D :
	 *  - lignes raster de la route (this.roadLines)
	 *  - objets du décor (this.scenerySprites)
	 *  - objets graphiques représentant les tunnels (this.tunnelSprites)
	 *  - image de l'horizon et du ciel (this.horizonPicture)
	 */
	initializeEnvironment : function()
	{
		var master = document.createElement('div');

		// création des lignes raster représentant la route
		for (var i=0; i<this.lineCount; ++i) {

			var currentLine = document.createElement('div');
			currentLine.style.position="absolute";
			currentLine.style.visibility="hidden";
			currentLine.style.left="0px";
			currentLine.style.top=(this.screenHeight-1-i)+"px";
			currentLine.style.width=this.screenWidth+"px";
			currentLine.style.height="1px";
			currentLine.style.overflow="hidden";
			
			var currentImg = document.createElement('img');
			//currentImg.setAttribute("src", "level001/roadline_g600.png");
			currentImg.style.position="absolute";
			currentImg.style.width=(2*i)+"px";
			//currentImg.style.height=this.road.phaseCount+"px";
			currentImg.style.left=Math.round(240-i)+"px";
			currentImg.style.top="0px";
			currentLine.appendChild(currentImg);
			master.appendChild(currentLine);

			this.roadLines[i]=currentLine;
			
		}
		
		// création des objets du décor qui jalonnent le parcours
		for (var i=0; i<this.objectCount ; ++i) {
			this.scenerySprites[i] = new CubicSprite (1, 1, 0, 0, 0, "", 1, 1);
			this.scenerySprites[i].appendToGraphicContainer(master);			
		}
		
		// création des murs des tunnels
		for (var i=0; i<this.tunnelSpriteCount; ++i) {
			this.tunnelSprites[i] = document.createElement('div');
			this.tunnelSprites[i].setAttribute("class", "race_tunnel_wall");
			master.appendChild(this.tunnelSprites[i]);
		}
		
		// création des plans météo
		for (var i=0; i<this.weatherPlaneCount ; ++i) {
			var currentObject = document.createElement('div');
			currentObject.setAttribute("class", "race_weather_plane");
			master.appendChild(currentObject);
			this.weatherPlane[i] = currentObject;
		}
		
		// création de l'image de fond (horizon)
		this.horizonPicture = document.createElement('div');
		this.horizonPicture.setAttribute("class", "race_horizon");
		master.appendChild(this.horizonPicture);
		
		// création du second plan (eau sur le circuit 4 par exemple)
		this.secondPlanePicture = document.createElement('div');
		this.secondPlanePicture.setAttribute("class", "race_second_plane");
		master.appendChild(this.secondPlanePicture);
		
		this.debugScreen = document.createElement('div');
		this.debugScreen.style.position="absolute";
		this.debugScreen.style.display="none";
		this.debugScreen.style.top="280px";
		this.debugScreen.style.width="640px";
		this.container.appendChild(this.debugScreen);
		
		// création de la voiture du joueur 
		this.playerSprite = new ZoomableSprite(140, 60, 70, 55,
								"cars/protonSprite.png", 15, 4);
		this.playerSprite.setZoom(1.0);
		this.playerSprite.setPosition((this.screenWidth-this.playerSprite.width)/2, this.screenHeight-64, -6);
		master.appendChild(this.playerSprite.getSpriteObject());
		
		// zone d'information texte
		this.infoPanelSpeed = this.imageFx.createOutlinedTextPanel(master, "lotus_info_speed", "lotus_info_text_small", "black");
		this.infoPanelRPM = this.imageFx.createOutlinedTextPanel(master, "lotus_info_rpm", "lotus_info_text_small", "black");

		this.infoPanelRPMBar = document.createElement('img');
		this.infoPanelRPMBar.setAttribute("class","lotus_info_rpm_bar");
		this.infoPanelRPMBar.setAttribute("src", this.baseDirectory+"common/rpm_bar.gif");
		this.infoPanelRPMBar.style.clip = "rect(0px 0px 20px 0px)";
		master.appendChild(this.infoPanelRPMBar);

		this.infoPanelGear = this.imageFx.createOutlinedTextPanel(master, "lotus_info_gear", "lotus_info_text_xlarge", "black");
		this.infoPanelTime = this.imageFx.createOutlinedTextPanel(master, "lotus_info_time", "lotus_info_text_small", "black");
		this.infoPanelBonusText = this.imageFx.createOutlinedTextPanel(master, "lotus_info_second_time", "lotus_info_text_small", "black");
		this.demoText = this.imageFx.createOutlinedTextPanel(master, "lotus_demo_text", "lotus_info_text_small", "black");

		this.createOpponents(master);
		this.container.appendChild(master);
		
		// debug : affichage du centre de masse en drift
		this.balanceView = document.createElement('div');
		this.balanceView.setAttribute("class","balance_view");
		master.appendChild(this.balanceView);
		
		this.xBalance = document.createElement('div');
		this.xBalance.setAttribute("class","x_balance");
		this.balanceView.appendChild(this.xBalance);
	
		this.yBalance = document.createElement('div');
		this.yBalance.setAttribute("class","y_balance");
		this.balanceView.appendChild(this.yBalance);
		
		this.frontLeftSkidPanel = document.createElement('div');
		this.frontLeftSkidPanel.setAttribute("class","wheel_skid_indicator");
		this.balanceView.appendChild(this.frontLeftSkidPanel);
		
		this.frontRightSkidPanel = document.createElement('div');
		this.frontRightSkidPanel.setAttribute("class","wheel_skid_indicator");
		this.frontRightSkidPanel.style.left="16px";
		this.balanceView.appendChild(this.frontRightSkidPanel);
		
		this.rearLeftSkidPanel = document.createElement('div');
		this.rearLeftSkidPanel.setAttribute("class","wheel_skid_indicator");
		this.rearLeftSkidPanel.style.top="32px";
		this.balanceView.appendChild(this.rearLeftSkidPanel);
		
		this.rearRightSkidPanel = document.createElement('div');
		this.rearRightSkidPanel.setAttribute("class","wheel_skid_indicator");
		this.rearRightSkidPanel.style.top="32px";
		this.rearRightSkidPanel.style.left="16px";
		this.balanceView.appendChild(this.rearRightSkidPanel);
		
		this.clickArea = document.createElement('div');
		this.clickArea.setAttribute("class", "transparent_click_area");
		this.container.appendChild(this.clickArea);

	},

	/**
	 * Crée l'ensemble des adversaires (au nombre de this.opponentCount)
	 *
	 * Les sprites sont ajoutés à l'objet DOM fourni, et stockés dans le tableau
	 * this.carSprites
	 *
	 * - master : objet DOM parent des <div> sprites des voitures
	 */
	createOpponents : function(master)
	{
		for (var index = 0; index<this.opponentCount; ++index)
		{				
			this.carSprites[index]=new ZoomableSprite(120, 60, 60, 54,
													  this.baseDirectory+"cars/protonSprite.png", 13, 2);
			master.appendChild(this.carSprites[index].getSpriteObject());
		}
	},

	launchDemo : function()
	{
		this.road = new Road();
		this.road.createDemoRoad1();
		this.setupRaceParameters();
		this.playerSprite.setVisible(false);
		this.horizonPicture.style.backgroundImage="url("+this.road.horizon.src+")";
		this.horizonPicture.style.backgroundPosition="-100px -80px";
		this.horizonPicture.style.backgroundRepeat="repeat-x";
		this.horizonPicture.style.visibility="visible";
		
		// TODO : prévoir l'appel au raceLoader pour la démo
		
		if (this.fader.faded) {
			this.fader.fadeIn();
		}
		
		var control=this;
		this.clickArea.onclick = function(event) { control.exitDemoMode(event); }
		document.onkeydown = function(event) { control.exitDemoMode(event); }

		this.raceStarted = true;
		this.runnableId = setInterval("demoMainLoop(lotusEngine)", 40); // 25 FPS
	},
	
	/**
	 * Lance une partie sur un circuit donné, déja chargé dans this.road
	 *  - réinitialisation des paramètres de course (timer, position joueur, position adversaires)
	 *  - branchement des handlers
	 *  - lancement de la main loop
	 */
	launchGame : function()
	{
		this.setupRaceParameters();
		this.playerSprite.setVisible(true);
		this.horizonPicture.style.backgroundImage="url("+this.road.horizon.src+")";
		this.horizonPicture.style.backgroundPosition="-100px -80px";
		this.horizonPicture.style.backgroundRepeat="repeat-x";
		this.horizonPicture.style.visibility="visible";
		
		if (this.road.horizon.reflection) {
			this.secondPlanePicture.style.backgroundImage="url("+this.road.horizon.reflection+")";
			this.secondPlanePicture.style.backgroundPosition="-100px 210px";
			this.secondPlanePicture.style.backgroundRepeat="repeat-x";
			this.secondPlanePicture.style.visibility="visible";
		}
		//this.balanceView.style.visibility="visible";
		this.imageFx.outlinedWrite(this.infoPanelBonusText, "");

		this.fader.fadeIn();
		
		var control = this;
		document.onkeydown = function(event) { return control.onKeyDown(event); }
		document.onkeyup = function(event) { control.onKeyUp(event); }

		this.runnableId = setInterval("gameMainLoop(lotusEngine)", 40); // 25 FPS
	},
	
	/**
	 * Reprend une partie suspendue par affichage de la EscapeWindow
	 */
	resumeRace : function()
	{
		this.raceEscaped = false;
		var control = this;
		document.onkeydown = function(event) { return control.onKeyDown(event); }
		document.onkeyup = function(event) { control.onKeyUp(event); }
		this.toggleSound(this.soundOn);

		this.runnableId = setInterval("gameMainLoop(lotusEngine)", 40); // 25 FPS
	},

	/**
	 * Lance le rejeu d'une course déja enregistrée
	 * à but d'observation ou d'administration (vérification d'un record)
	 */ 
	launchReplay : function(ghost)
	{
		this.setupRaceParameters();
		this.ghostRecord = ghost;
		
		this.playerSprite.setVisible(true);
		this.horizonPicture.style.backgroundImage="url("+this.road.horizon.src+")";
		this.horizonPicture.style.backgroundPosition="-100px -80px";
		this.horizonPicture.style.backgroundRepeat="repeat-x";
		this.horizonPicture.style.visibility="visible";
		
		if (this.road.horizon.reflection) {
			this.secondPlanePicture.style.backgroundImage="url("+this.road.horizon.reflection+")";
			this.secondPlanePicture.style.backgroundPosition="-100px 290px";
			this.secondPlanePicture.style.backgroundRepeat="repeat-x";
			this.secondPlanePicture.style.visibility="visible";
		}

		this.fader.fadeIn();
		
		this.runnableId = setInterval("replayMainLoop(lotusEngine)", 40); // 25 FPS
	},
	
	/**
	 * Cache (rend invisibles) les éléments DHTML composant la scène 3D :
	 *  - lignes raster de la route
	 *  - objets du décor
	 *  - horizon
	 *  - voitures adverses
	 *  - voiture du joueur
	 *
	 * L'intérêt de cette méthode est de pouvoir afficher des écrans intermédiaires
	 * entre la démo et la course, ou entre deux courses, sans détruire les objets DHTML
	 * de la scène, afin qu'ils resservent pour la course suivante.
	 */
	hideScene : function()
	{
		// effacement des lignes de la route
		for (var i=0; i<this.lineCount; ++i) {
			this.roadLines[i].style.visibility="hidden";
		}
		
		// effacement du décor 
		for (var i=0; i<this.objectCount ; ++i) {
			this.scenerySprites[i].setVisible(false);
		}
		
		// effacement des murs des tunnels
		for (var i=0; i<this.tunnelSpriteCount ; ++i) {
			this.tunnelSprites[i].style.visibility="hidden";
		}
		
		// effacement des plans météo
		for (var i=0; i<this.weatherPlaneCount ; ++i) {
			this.weatherPlane[i].style.visibility="hidden";
		}
		
		// effacement de l'horizon
		this.horizonPicture.style.visibility="hidden";
		this.secondPlanePicture.style.visibility="hidden";

		for (var index = 0; index<this.opponentCount; ++index)
		{				
			this.carSprites[index].setVisible(false);
		}

		
		// effacement des zones d'info texte (en y écrivant des chaînes vides)
		this.imageFx.outlinedWrite(this.infoPanelSpeed, "");
		this.imageFx.outlinedWrite(this.infoPanelRPM, "");
		this.infoPanelRPMBar.style.clip = "rect(0px 0px 20px 0px)";
		this.imageFx.outlinedWrite(this.infoPanelGear, "");		
		this.imageFx.outlinedWrite(this.infoPanelTime, "");
		this.imageFx.outlinedWrite(this.infoPanelBonusText, "");
		this.imageFx.outlinedWrite(this.demoText, "");
		
		this.clickArea.style.visibility="hidden";
		
		this.balanceView.style.visibility="hidden";
	},	
 	
	
	/**
	 * Boucle principale d'animation et de calcul de la démo
	 * appelée pour le calcul et l'affichage de chaque frame via setInterval()
	 */
	demoMainLoop : function()
	{
		// mouvements adversaires
		this.moveOpponentCars();
		
		// réaffichage pseudo-3D
		this.animateRoad();
		
		// message de démo
		if ((this.frameCounter%150)==0) {
			this.imageFx.outlinedWrite(this.demoText, _("EN001"));
		}
		if ((this.frameCounter%150)==75) {
			this.imageFx.outlinedWrite(this.demoText, _("EN002"));
		}
		this.scrollDemoText(this.frameCounter);
		
		// mesure du temps 
		var current = new Date();
		var currentTime = current.getTime();
		var fpsCounter = this.frameCounter%5;
		var elapsed = currentTime - this.fpsMeasure[fpsCounter];
		this.fpsMeasure[fpsCounter] = currentTime;
		++this.frameCounter;
		var fps = (elapsed == currentTime ? "NS" : 5000 / elapsed);
		
		this.debugScreen.innerHTML=(fps=="NS" ? fps : fps.toFixed(1)+" fps");
		
		if (this.exitDemo) {
			this.clickArea.onclick = null;
			document.onkeydown = null;
			if (!this.fader.fading) { // le rideau est à l'arrêt
				if (this.fader.faded) { // soit il est déja tiré
					clearInterval(this.runnableId);
					this.hideScene();
					this.application.afterDemo();
				} else { // soit on est au tout début et on doit le tirer
					this.fader.fadeOut();
				}
			}			
		}
	},
	
	gameMainLoop : function()
	{
		if (this.pause) {
			this.scrollDemoText(this.pauseFrameCounter++);
			return;
		}
	
		if (this.ghostInUse && this.raceStarted) {
			this.updateGhost();
		}
		
		// mouvements joueur et adversaires
		this.moveOneStep();
		if (this.soundOn) {
			this.audioContext.currentTime = this.engineRPM/800;
			//this.audioContext.play();
		}
		
		if (!this.raceEnded) {
			this.raceTime += 4; // pas de 4/100e de seconde (25 fps)
			
			// télémétrie
			if (this.raceStarted) {
				this.recordRace();
			}
		}
		
		// détection de fin de partie - franchissement de la ligne d'arrivée
		if (!this.raceEnded && this.playerZ >= this.road.finishLine) {
			this.raceEnded = true;			
			this.raceResult.initialize (this.raceTime, this.road);
			// envoi de la validation via Ajax 
			this.road.validateRace(this.controlsType, this.playerCar.index, this.raceTime, this.recording, this.raceResult);
		}

		
		// mise à jour des infos affichées
		this.updatePanels();
		
		// réaffichage pseudo-3D
		if (!this.skipOddFrames || (this.frameCounter&1)) {
			this.animateRoad();
		}
		
		// mesure du temps 
		var current = new Date();
		var currentTime = current.getTime();
		var fpsCounter = this.frameCounter%5;
		var elapsed = currentTime - this.fpsMeasure[fpsCounter];
		this.fpsMeasure[fpsCounter] = currentTime;
		++this.frameCounter;
		var fps = (elapsed == currentTime ? "NS" : 5000 / elapsed);
		
		if (this.controlsType<3) {
			//this.debugScreen.innerHTML=(fps=="NS" ? fps : fps.toFixed(1)+" fps");
		}
			
		// sortie de l'écran : fin de course ou échappement
		if ((this.raceEnded && this.playerSpeed == 0) || this.raceEscaped) {
			this.pauseSound();
			document.onkeydown = null;
			document.onkeyup = null;
			clearInterval(this.runnableId);
			this.runnableId = -1;
			if (this.raceEscaped) {
				this.application.afterRaceSuspended();
			} else {
				this.application.afterRaceCompleted();
			}
		}		
	},

	/**
	 * Boucle principale d'animation et d'affichage du rejeu
	 * appelée pour le calcul et l'affichage de chaque frame via setInterval()
	 */
	replayMainLoop : function()
	{
		if (this.raceStarted) {
			this.updateGhost();
		}
				
		// mouvements joueur enregistrés
		this.replayOneStep();
		
		if (!this.raceEnded) {
			this.raceTime += 4; // pas de 4/100e de seconde (25 fps)
		}
	
		// détection de fin de partie - franchissement de la ligne d'arrivée
		if (!this.raceEnded && this.playerZ >= this.road.finishLine) {
			this.raceEnded = true;			
		}
		
		// mise à jour des infos affichées
		this.updatePanels();
		
		// réaffichage pseudo-3D
		this.animateRoad();
		
		// mesure du temps 
		var current = new Date();
		var currentTime = current.getTime();
		var fpsCounter = this.frameCounter%5;
		var elapsed = currentTime - this.fpsMeasure[fpsCounter];
		this.fpsMeasure[fpsCounter] = currentTime;
		++this.frameCounter;
		var fps = (elapsed == currentTime ? "NS" : 5000 / elapsed);
		
		this.debugScreen.innerHTML=(fps=="NS" ? fps : fps.toFixed(1)+" fps");
		
			
		// fin de la course
		if (this.raceEnded && this.playerSpeed == 0) {
			clearInterval(this.runnableId);
		}		
	},
 
    /**
	  * Dessine la route et le décor à la position courante.
	  *
	  * Le dessin se fait en modifiant les images de fond des <div> représentant la route, sans création ni déplacement d'objets DOM.
	  */
	animateRoad : function()
	{
		// les distances sont données en cm
		var currentLineIndex = 0;
		var currentObjectIndex = 0;
		var currentTunnelIndex = 0; // indice du sprite "mur"
		var focal = this.camera.focal;
		var defaultCameraY = this.camera.posY; // hauteur de la caméra au sol
		var playerReferenceZ = 1100; // distance entre la voiture et la caméra
		var visibilityBehind = 400; // distance visible en arrière du joueur (pour les voitures adverses)
		var centerX = this.screenWidth/2; // nombre de pixels jusqu'au centre de l'écran
		if (this.playerZ <= this.road.finishLine) {
			this.camera.posX = this.playerX;
			this.camera.posZ = this.playerZ-playerReferenceZ;
		}
		var cameraX = this.camera.posX;
		var cameraZ = this.camera.posZ;
		var cameraTarget = cameraZ+playerReferenceZ; // point visé par la caméra. Défaut = la voiture du joueur
		var maxObjectDepth = this.objectCount>>1;
		
		var maxDistance = 20000;
		var roadJolt = 0; // secousses et cahots en dehors de la route
		if (this.offRoad && this.playerSpeed > 0) {
			roadJolt = (this.raceTime&8 ? 1 : 0);
		}
		
		var leftWall = 0; // position du mur du tunnel, qui fait écran aux lignes raster suivantes
		var rightWall = this.screenWidth;
		var tunnelEntranceLeft = 0; // position du mur du tunnel à l'entrée, pour affichage de la div en sortie
		var tunnelEntranceRight = this.screenWidth;
		var tunnelEntranceTop = 0;
		
		// calcul de la première keyframe : la plus loin avant la focale
		// attention cela suppose focal > this.distanceStep pour éviter d'avoir une keyframe derrière la caméra (sinon, ajouter un offset)
		var firstKeyframe = cameraZ + focal;
		firstKeyframe = this.distanceStep*Math.round(firstKeyframe / this.distanceStep);
		var distance = firstKeyframe - cameraZ;
		
		// Calcul de l'angle *vertical* de la caméra 
		// L'angle au milieu de deux keyframes correspond à la pente de la route (différence d'altitude entre les deux keyframes)
		// Entre ces points milieu on fait une interpolation linéaire. Le calcul nécessite donc trois points.
		var keyframeBeforePlayerIndex = Math.floor(cameraTarget / this.distanceStep);
		var keyframeBeforePlayer = this.distanceStep*keyframeBeforePlayerIndex;
		var altitudeBefore = this.road.roadAltitude[keyframeBeforePlayerIndex%this.road.roadLength];
		var altitudeAfter = this.road.roadAltitude[(keyframeBeforePlayerIndex+1)%this.road.roadLength];
		var cameraAngle = 0 ;
		var alphaKeyframe = (cameraTarget-keyframeBeforePlayer)/this.distanceStep;
		if (alphaKeyframe<0.5) {
			var formerAltitude = this.road.roadAltitude[(keyframeBeforePlayerIndex-1)%this.road.roadLength];
			cameraAngle = (0.5+alphaKeyframe)*(altitudeBefore-altitudeAfter)
			              +(0.5-alphaKeyframe)*(formerAltitude-altitudeBefore);
		} else {
			var nextAltitude = this.road.roadAltitude[(keyframeBeforePlayerIndex+2)%this.road.roadLength];
			cameraAngle = (1.5-alphaKeyframe)*(altitudeBefore-altitudeAfter)
			              +(alphaKeyframe-0.5)*(altitudeAfter-nextAltitude);
		}
		
		// ligne d'horizon : normalement (vue à plat) à 80 pixels du bas de l'écran, soit 160 du haut.
		// L'horizon sur les images de fond (1720*520) est à 290 pixels du haut, il faut donc un placement de
		// l'image à 130 pixels au-dessus (130 + 160 = 290). On l'atteint avec 80 (horizon par défaut) + 50.
		var horizonLine = Math.round(cameraAngle*focal/this.distanceStep)+this.defaultHorizon+50;
		var cameraY = (1-alphaKeyframe)*altitudeBefore+alphaKeyframe*altitudeAfter+defaultCameraY+cameraAngle*(cameraTarget-cameraZ)/this.distanceStep;		
		//  "la ligne 0" correspondant à la position du joueur doit avoir
		//  un offset et un angle de zéro : on initialise à la keyframe précédente
		var targetAlphaZ = (cameraTarget - keyframeBeforePlayer)/this.distanceStep; // 0 at previous, 1 at next
		var angleBefore = this.road.roadAngle[keyframeBeforePlayerIndex%this.road.roadLength];
		var angleAfter = this.road.roadAngle[(keyframeBeforePlayerIndex+1)%this.road.roadLength];
		// r0 = -A -alpha.B
		var roadAngleX = -angleBefore-targetAlphaZ*angleAfter;		

		var cameraShift = 0;
		if (this.cameraView == 1) { // alignement de la caméra sur l'angle de la voiture
			cameraShift = this.normalizeAngle(this.playerAngle*3.1416/36, 0)*36/(3.1416*20);
		} else if (this.cameraView ==2) { // alignement de la caméra sur le vecteur vitesse
			cameraShift = (this.normalizeAngle((this.roadHeading*3.1416/36)-this.speedDirection, 0)*36/3.1416)/20;
		} else if (this.cameraView==3) { // angle custom par rapport à la voiture
			cameraShift = this.normalizeAngle((this.playerAngle+this.customCameraAngle)*3.1416/36, 0)*36/(3.1416*20);
		}
		roadAngleX += cameraShift;
		cameraX -= cameraShift;
		
		// l0 = -alpha (r0+A)
		var previousLateralOffset = -targetAlphaZ*(roadAngleX+angleBefore);

		

		distance = keyframeBeforePlayer - cameraZ;
		var currentKeyframe = keyframeBeforePlayerIndex; // keyframe à (distance+cameraZ)
		
		var zoomFactor = 240000;
		
		var topLine = this.defaultHorizon+roadJolt; // ligne la plus haute atteinte lors du dessin
		var previousDistance = distance - this.distanceStep;
		var previousAltitude = this.road.roadAltitude[(currentKeyframe-1)%this.road.roadLength] + cameraAngle*previousDistance/this.distanceStep;
/*
		var debugText = "cameraHeight = "+cameraY+", camera angle = "+cameraAngle+", alphaZ = "+targetAlphaZ+", offset = "+previousLateralOffset+"<br>";
		debugText += "pre-computed altitude at player Z = "+((1-alphaKeyframe)*altitudeBefore+alphaKeyframe*altitudeAfter)+"<br>";
		
*/
		var debugText = "player : X = "+this.playerX+", Z = "+this.playerZ+", keyframe = "+keyframeBeforePlayerIndex+"<br>";
		
		// changement de section au sol
		// on utilise une zone n+1 (dans le tableau) pour servir de buffer de fin
		var nextSectionOffset = this.distanceStep*this.road.groundSection[this.roadSectionIndex+1].offset;
		if (nextSectionOffset <= keyframeBeforePlayer /*cameraZ*/) {
			++this.roadSectionIndex;
			nextSectionOffset = this.distanceStep*this.road.groundSection[this.roadSectionIndex+1].offset;
		}
		var insideTunnel = this.road.groundSection[this.roadSectionIndex].isTunnel;
		var enteringTunnel = false;
		var leavingTunnel = false;
		var groundDescription = this.road.groundSection[this.roadSectionIndex];
		var currentRoadSectionIndex = this.roadSectionIndex;
		
		// changement de zone météo
		// pas de zone n+1 ici, on teste la longueur du tableau
		// (approches à comparer et à uniformiser sur la meilleure des deux)
		if (this.weatherAreaIndex+1 < this.road.weatherArea.length) {
			var nextAreaOffset = this.distanceStep*this.road.weatherArea[this.weatherAreaIndex+1].offset;
			if (nextAreaOffset <= cameraZ) {
				++this.weatherAreaIndex;
				if (this.weatherAreaIndex+1 < this.road.weatherArea.length) {
					nextAreaOffset = this.distanceStep*this.road.weatherArea[this.weatherAreaIndex+1].offset;
				} else {
					nextAreaOffset = this.distanceStep*this.road.roadLength*2;
				}
			}
		}
		var currentWeatherAreaIndex = this.weatherAreaIndex;
		
		// on cache tous les adversaires derrière la caméra
		// (rappel : la variable opponentData est ordonnée par Z croissant)
		// le calcul de comparaison se fait modulo this.road.roadLength
		// (en absolu cela donnerait z + visibilityBehind < cameraTarget)
		var roadLength = this.road.roadLength*this.distanceStep; // longueur de la route en m (et non plus en segments)
		var currentOpponent = 0; // index de la prochaine voiture adverse à dessiner
		while (currentOpponent<this.opponentCount 
			   && ((this.opponentData[currentOpponent].z+visibilityBehind - cameraTarget + roadLength)%roadLength)>roadLength/2) 
		{
			var debugDeltaZ = (this.opponentData[currentOpponent].z+visibilityBehind - cameraTarget + roadLength)%roadLength;
			//debugText+="opponent at "+this.opponentData[currentOpponent].z+", hidden behind. Delta = "+debugDeltaZ+", Speed="+this.opponentData[currentOpponent].speed+"<br>";
			this.carSprites[currentOpponent].setVisible(false);
			++currentOpponent;
		}
		
		
		// Algorithme de dessin : on travaille par ligne raster en partant du bas de l'écran vers le haut(de la caméra en s'éloignant vers l'avant)
		// Le premier niveau d'itération se fait sur les keyframes. Par projection, chaque keyframe devant la caméra correspond à une ligne raster.
		// La correspondance n'est pas bijective : sur les plus proches les lignes sont espacées, en s'éloignant vers l'horizon plusieurs keyframes
		// peuvent se retrouver sur la même ligne raster en raison de la discrétisation. De plus, si la route descend (typiquement derrière une colline), 
		// une keyframe peut correspondre à une ligne plus basse que la précédente.
		//
		// On s'arrête si on atteint le maximum de lignes raster (200 pour un écran de 240), ou à la distance de clipping
		// Le clipping en profondeur nous limite à quelques pixels en-dessous de l'horizon.
		while (distance<maxDistance && currentLineIndex<this.lineCount) {
		
			var altitude = this.road.roadAltitude[currentKeyframe%this.road.roadLength] + cameraAngle*distance/this.distanceStep;
			var localAngle = this.road.roadAngle[currentKeyframe%this.road.roadLength];
			roadAngleX += localAngle;
			var lateralOffset = previousLateralOffset + roadAngleX;
			// utilisation de ceil() et non round() : un arrondi par défaut (32.49 -> 32) à une valeur non atteinte
			// dans la projection peut causer un effet de bord sur la projection inverse : la valeur calcule de intDistance  
			// devient alors négative.
			var y = Math.ceil(-focal*(altitude-cameraY)/distance);
			
			var size = Math.round(zoomFactor/distance);
			//debugText+="dist="+distance+" , y="+y+", size="+size+", index="+currentLineIndex+"<br>";

			// pente de la route : dy= [y(d2)-y(d1) ] / [d2 - d1]
			var dy = (altitude-previousAltitude)/this.distanceStep;
			var lateralOffsetStep = (lateralOffset-previousLateralOffset)/this.distanceStep;
			var previousDistance = distance - this.distanceStep;
			
			// L'algorithme passe une fois et une seule sur chaque ligne raster. Il maintient une variable "flotteur" qui indique la ligne la plus haute
			// dessinée. Si la keyframe correspond à une ligne qui est en-dessous, on boucle directement à la suivante.
			// En particulier, en début de dessin, le flotteur est sur le bas de l'écran, afin d'ignorer tout ce qui dépasse.
			if (y<topLine && size>0 && y<this.lineCount) {
				
				//debugText+="line from "+(topLine-1)+" to "+(y)+"<br>  ";
								
				// Une deuxième boucle à l'intérieur de la première, cette fois au niveau des lignes raster.
				// Elle sert pour le dessin à proximité de la caméra, pour remplir les lignes entre les keyframes.
				// Ces lignes sont calculées par interpolation linéaire des coordonnées (altitude et decalage latéral).
				// A noter que la distance n'est pas découpée linéairement en tronçons entre les keyframes, ce qui serait faux, 
				// à la place chaque ligne raster est reprojetée pour en calculer la distance vraie.
				for (var intLine = topLine-1; intLine>=y; --intLine) {
					var intDistance = focal*(cameraY+previousDistance*dy-previousAltitude)/(intLine+focal*dy);
					var intSize = Math.round(zoomFactor/intDistance);
					var intLateralOffset = previousLateralOffset+(intDistance-previousDistance)*lateralOffsetStep;

					// 0.0 au début de la section, 1.0 à la fin
					var alphaInSection = (intDistance+cameraZ-this.distanceStep*groundDescription.offset)/(nextSectionOffset-this.distanceStep*groundDescription.offset);
					
					var phase = (intDistance+cameraZ)%groundDescription.patternDistance;
					phase = Math.floor(phase*groundDescription.patternSize/groundDescription.patternDistance);
					
/*					
					var intAltitude = previousAltitude+(intDistance-previousDistance)*dy;

					debugText+=intLine+":"+Math.round(intDistance)+","+intSize+" ";
					if (intLine == this.defaultHorizon-1) {
						debugText+="<b>line "+intLine+" : </b><br />";
						debugText+="delta="+(intDistance-previousDistance)+", offset= <";
						debugText+=previousLateralOffset+", "+lateralOffset+" :"+intLateralOffset+">";
						debugText+=" distance="+Math.round(intDistance)+", altitude="+intAltitude+", size="+intSize+"<br>";
					}
					if (intDistance>playerReferenceZ-12 && intDistance<playerReferenceZ+5) {
						debugText+="<b>around distance "+playerReferenceZ+" : </b><br />";
						debugText+="delta="+(intDistance-previousDistance)+", offset= <";
						debugText+=previousLateralOffset+", "+lateralOffset+" :"+intLateralOffset+">";
						debugText+=" distance="+Math.round(intDistance)+", altitude="+intAltitude+", line="+intLine+", size="+intSize+"<br>";	
						debugText+=" keyframe1="+previousDistance+", angle="+this.road.roadAngle[(currentKeyframe-1)%this.road.roadLength]+"<br>";
						debugText+=" keyframe="+distance+", angle="+localAngle+"<br>";
					}
*/
					
					// les lignes raster sont déja en place sous forme de <div> avec la couleur de fond,
					// contenant une <img> avec l'image de la route.
					// la div est fixe en Y, seul son z-index change ainsi que sa couleur de fond
					// le X peut bouger dans le cas où la div est limitée en taille à gauche
					// (groundLeft dans la section). Sa largeur peut de même être réduite (groundRight dan
					// la section).
					// L'image incluse dans la div se déplace en X pour les virages, en Y pour la phase 
					// (elle contient tous les graphismes possibles, mais une seule ligne en est visible 
					// car la div fait 1 px de haut), et en largeur pour correspondre à la profondeur.
					// La même image est utilisée pour toutes les lignes à des niveaux de zoom différents
					// Dans les tunnels, les murs font écran à la visibilité : on coupe à gauche et à droite
					// (clip) tout ce qui est caché par le mur. La partie coupée sera affichée avec la couleur
					// de fond, qui doit être définie en noir dans la section correspondante.
					var pixelOffsetX = Math.round((cameraX+intLateralOffset)*zoomFactor/intDistance);
					var rasterLeft = 0;
					if (groundDescription.groundLeft) {
						var alphaLeft = alphaInSection*(groundDescription.groundLeft.length-1);
						var alphaLeftInt = Math.floor(alphaLeft);
						var alphaLeftFraction = alphaLeft-alphaLeftInt;
						var unitLeft = groundDescription.groundLeft[alphaLeftInt]*(1-alphaLeftFraction)
						              +groundDescription.groundLeft[alphaLeftInt+1]*alphaLeftFraction;
						rasterLeft = centerX+Math.round(unitLeft*zoomFactor/intDistance)-pixelOffsetX;
						rasterLeft = (rasterLeft<0?0:(rasterLeft>this.screenWidth?this.screenWidth:rasterLeft));
					}
					var rasterRight=this.screenWidth;
					if (groundDescription.groundRight) {
						var alphaRight = alphaInSection*(groundDescription.groundRight.length-1);
						var alphaRightInt = Math.floor(alphaRight);
						var alphaRightFraction = alphaRight-alphaRightInt;
						var unitRight = groundDescription.groundRight[alphaRightInt]*(1-alphaRightFraction)
						               +groundDescription.groundRight[alphaRightInt+1]*alphaRightFraction;
						rasterRight = centerX+Math.round(unitRight*zoomFactor/intDistance)-pixelOffsetX;
						rasterRight = (rasterRight<0?0:(rasterRight>this.screenWidth?this.screenWidth:rasterRight));
					}
					
					var rasterLine = this.roadLines[currentLineIndex];
					var rasterImgStyle = rasterLine.firstChild.style;
					var imageLeft = centerX-intSize-pixelOffsetX-rasterLeft;
					rasterImgStyle.width=(2*intSize)+"px";
					rasterImgStyle.left=imageLeft+"px";
					rasterImgStyle.top=(-groundDescription.groundLine[phase])+"px";
					rasterImgStyle.clip="rect(0px "
											+(rightWall-imageLeft)+"px 50px "
											+(leftWall-imageLeft)+"px)";
					rasterLine.style.visibility="visible";
					rasterLine.style.left=rasterLeft+"px";
					rasterLine.style.width=(rasterRight-rasterLeft)+"px";
					rasterLine.style.zIndex=-Math.round(intDistance/160);
					rasterLine.style.backgroundColor = groundDescription.groundColor[phase];
					
					
					++currentLineIndex;
				}
				
				topLine = y;
			}

			
			// On dessine les voitures adverses qui se trouvent entre les deux keyframes
			// Ce dessin ne peut pas être fait dans la boucle des lignes, car dans les cas de backside clipping
			// (la route redescend, l'index de la ligne augmente avec la distance, on est "derrière" une colline)
			// l'itération ne se fait même pas. Si une voiture se trouvait là, elle serait dessinée avec la prochaine
			// ligne affichée, et donc nécessairement au mauvais endroit.
			// On le fait avant l'affichage des tunnels (et donc avant le recalcul des leftWall et rightWall) 
			// car les voitures sont devant la keyframe courante : sinon le masque les couperait trop courtes sur les côtés
			while (currentOpponent<this.opponentCount 
				   && ((this.opponentData[currentOpponent].z-cameraZ)%roadLength)<distance) {
				var intDistance = (this.opponentData[currentOpponent].z-cameraZ)%roadLength;
				var intAltitude = previousAltitude+(intDistance-previousDistance)*dy;
				var intSize = zoomFactor/intDistance;
				var intLine = Math.round(-focal*(intAltitude-cameraY)/intDistance);
				var intLateralOffset = previousLateralOffset+(intDistance-previousDistance)*lateralOffsetStep;
				
				var currentOpponentData = this.opponentData[currentOpponent];
				var opponentSprite = this.carSprites[currentOpponent];
				var carRelativeSize = playerReferenceZ/intDistance;
				var px = centerX-Math.round((cameraX+intLateralOffset-currentOpponentData.x)*intSize);
				var py = this.screenHeight-this.defaultHorizon+intLine-roadJolt;
				
				// orientation apparente de la voiture : dépend de son orientation réelle et de sa position latérale par rapport au joueur
				// formule : alpha = theta - arctan (dX/dZ). theta = angle de la voiture par rapport au "nord"
				// coefficient 15 estimé entre données entre radians (1,57 pour 1/4 de tour) et "pas" d'animation (1 pas = 5°)
				// coefficient 600 entre largeur unité de route (de -1 à 1 soit 2 unités) et largeur en cm (12 m = 1200 cm)
				// coefficient 10 entre angle de la route calculé en delta cumulatif et unité de calcul
				var viewAngle = Math.round(currentOpponentData.angle-10*roadAngleX-15*Math.atan2(600*(currentOpponentData.x-cameraX), intDistance));
				if (viewAngle<-6) {
					viewAngle=-6;
				} else if (viewAngle>6) {
					viewAngle = 6;
				}
				var isBraking = (currentOpponentData.targetSpeed < currentOpponentData.speed);

				opponentSprite.setClipArea(0, rightWall, this.screenHeight, leftWall);
				opponentSprite.moveSpriteTo(px, py, -intDistance/160, carRelativeSize);
				opponentSprite.setAnimationFrame(viewAngle+6, (isBraking?1:0));
				opponentSprite.setVisible(true);


				var turnAngle = 10*roadAngleX;
				var deltaAngle = -8*Math.atan2(600*(currentOpponentData.x-cameraX), intDistance);
				/*
				debugText+="opponent at "+this.opponentData[currentOpponent].z+", visible at line "+intLine+". X="
					+this.opponentData[currentOpponent].x+", target="+this.opponentData[currentOpponent].targetX
					+", showing at "+px
					+". Speed="+this.opponentData[currentOpponent].speed+", z-index="+(-Math.round(intDistance/160))
					+", ownAngle = "+currentOpponentData.angle+", turnAngle = "+turnAngle+", deltaAngle = "+deltaAngle+" <br>";
					*/

				++currentOpponent;
			}
			
			
			// on vérifie si le nouveau morceau de route se trouve dans une nouvelle section.
			// Ce changement est fait après affichage des lignes raster, car celles-ci sont situées
			// entre la keyframe précédente et la courante, alors que l'entrée de tunnel se fait au
			// niveau de la keyframe courante
			if (nextSectionOffset <= distance+cameraZ) {
				++currentRoadSectionIndex;
				groundDescription = this.road.groundSection[currentRoadSectionIndex];
				// identification des entrées/sorties de tunnel, qui seront appliquées plus loin à la variable
				// insideTunnel. Cela pour permettre le recalcul des leftWall et rightWall
				enteringTunnel = this.road.groundSection[currentRoadSectionIndex].isTunnel && !insideTunnel;
				leavingTunnel = !this.road.groundSection[currentRoadSectionIndex].isTunnel && insideTunnel;
				nextSectionOffset = this.distanceStep*this.road.groundSection[currentRoadSectionIndex+1].offset;
			}

			
			// Si on est dans un tunnel, on réajuste le clipping par les murs à la largeur 
			// de la route.
			// Il suffit de les faire à chaque keyframe car les points extrêmes seront 
			// nécessairement dessus : entre deux keyframes il s'agit d'une interpolation
			// linéaire, donc dans l'intervalle entre les deux extrémités en X.
			
			insideTunnel = (insideTunnel || enteringTunnel) && (!leavingTunnel);
			if (insideTunnel||leavingTunnel) {
				var pixelOffsetX = Math.round((cameraX+lateralOffset)*zoomFactor/distance);				
				var imageWidth = 2*size;
				var imageLeft = centerX-size-pixelOffsetX;
				
				leftWall = Math.min(rightWall, Math.max(imageLeft, leftWall)); // move left boundary to the right, but never exceed rightWall
				rightWall = Math.max(leftWall, Math.min(imageLeft+imageWidth-1, rightWall)); // move right boundary to the left, never crossing leftWall
			}
			if (leavingTunnel) {
				var currentSection = this.road.groundSection[currentRoadSectionIndex-1];
				var currentTunnel = this.tunnelSprites[currentTunnelIndex];
				var cy = this.screenHeight-this.defaultHorizon+y-roadJolt;
				var exitHeight = Math.round(currentSection.tunnelInnerHeight*playerReferenceZ/distance);
				var topW = cy-tunnelEntranceTop-exitHeight;
				var leftW = leftWall - tunnelEntranceLeft;
				var rightW = tunnelEntranceRight-rightWall;
				currentTunnelIndex++;
				currentTunnel.style.left = tunnelEntranceLeft+"px";
				currentTunnel.style.top = tunnelEntranceTop+"px";
				currentTunnel.style.width = (tunnelEntranceRight-tunnelEntranceLeft-rightW-leftW)+"px";
				currentTunnel.style.height = exitHeight+"px";
				currentTunnel.style.borderWidth = topW+"px "+rightW+"px 0px "+leftW+"px";
				currentTunnel.style.borderColor = currentSection.tunnelInnerColor;
				currentTunnel.style.zIndex=Math.round(-distance/160);
				currentTunnel.style.visibility="visible";
			}
			if (enteringTunnel) {
				var currentSection = this.road.groundSection[currentRoadSectionIndex];
				var currentTunnel = this.tunnelSprites[currentTunnelIndex];
				var cy = this.screenHeight-this.defaultHorizon+y-roadJolt;
				var sizeY = Math.round(currentSection.tunnelOuterHeight*playerReferenceZ/distance);
				var topW = Math.round((currentSection.tunnelOuterHeight-currentSection.tunnelInnerHeight)*playerReferenceZ/distance);
				var rightW = this.screenWidth-rightWall;
				currentTunnelIndex++;
				currentTunnel.style.left = "0px";
				currentTunnel.style.top = (cy-sizeY)+"px";
				currentTunnel.style.width = (this.screenWidth-leftWall-rightW)+"px";
				currentTunnel.style.height = (cy>this.screenHeight?this.screenHeight-cy+sizeY-topW:sizeY-topW)+"px";
				currentTunnel.style.borderWidth = topW+"px "+rightW+"px 0px "+leftWall+"px";
				currentTunnel.style.borderColor = currentSection.tunnelOuterColor;
				currentTunnel.style.zIndex=Math.round(-distance/160);
				currentTunnel.style.visibility="visible";
				tunnelEntranceLeft = leftWall;
				tunnelEntranceRight = rightWall;
				tunnelEntranceTop = cy-sizeY+topW;
			}
			leavingTunnel = enteringTunnel = false;

			
			// déplacement du sprite de la voiture du joueur si on a passé la ligne d'arrivée (la caméra restant fixe par rapport à la route)
			if (this.playerZ > this.road.finishLine) {
				var intDistance = this.playerZ - cameraZ;
				if (previousDistance < intDistance && intDistance < distance) {
					var intAltitude = previousAltitude+(intDistance-previousDistance)*dy;
					var intSize = Math.round(zoomFactor/intDistance);
					var intLine = Math.round(-focal*(intAltitude-cameraY)/intDistance);
					var intLateralOffset = previousLateralOffset+(intDistance-previousDistance)*lateralOffsetStep;
					
					var carRelativeSize = playerReferenceZ/intDistance;
					var px = centerX-Math.round((cameraX+intLateralOffset)*zoomFactor/intDistance-this.playerX*intSize);
					var py = this.screenHeight-this.defaultHorizon+intLine;
/*
					var sizeX = Math.round(140*carRelativeSize);
					var px1 = px-sizeX/2;
					var sizeY = Math.round(64*carRelativeSize);
					var py1 = py - sizeY+1;
					*/
					this.playerSprite.moveSpriteTo(px, py, -intDistance/160, carRelativeSize);
					this.playerSprite.setVisible(true);						
				}
				
			}
			
			
			// Affichage des objets du décor.
			// Les objets sont nécessairement sur les lignes des keyframes.
			// Il peut y avoir deux objets par keyframe, arbitrairement nommés "gauche" et "droite", cependant
			// aucune contrainte ne s'applique sur leur abscisse, deux objets peuvent être du même côté 
			//
			// L'affichage se fait selon le principe d'un "buffer circulaire" : d'un affichage sur l'autre,
			// une keyframe, donc l'objet du décor correspondant, sera toujours représenté par le même objet DOM
			// (même index de scenerySprites) qu'il soit au premier plan ou plus en arrière. 
			// Un objet CubicSprite stocké dans scenerySprite représente donc le même objet du décor, depuis son
			// apparition à la distance de clipping, jusqu'à sa disparition derrière le plan focal de la caméra.
			// Cela ouvre la possibilité d'optimiser les appels (pas de changement de la source de l'image par ex.)
			
			if (currentKeyframe-keyframeBeforePlayerIndex<maxObjectDepth) {
				var reuse = (currentKeyframe-keyframeBeforePlayerIndex == maxObjectDepth-2);
				var currentObjectIndex = 2*(currentKeyframe%maxObjectDepth);
				//debugText+=currentObjectIndex+":"+currentKeyframe+(reuse?"r":"")+" ";
				var objectRelativeSize = playerReferenceZ/distance;

				var obstacleLeft = this.road.sceneryLeft[currentKeyframe%this.road.roadLength];
				var obstacleRight = this.road.sceneryRight[currentKeyframe%this.road.roadLength];
				//this.debugScreen.innerHTML=debugText;
				var currentSprite = this.scenerySprites[currentObjectIndex];
				
				if (obstacleLeft.type >= 0) {
					var currentMaster = this.road.sceneryMaster[obstacleLeft.type];
					var distanceToViewCenter = obstacleLeft.x-cameraX-lateralOffset;
					var sizeX = Math.round(currentMaster.frontW*objectRelativeSize);
					var px = centerX+Math.round(distanceToViewCenter*zoomFactor/distance-sizeX/2);
					var cx = centerX+Math.round(distanceToViewCenter*zoomFactor/distance);
					// clipping latéral
					if (px<this.screenWidth && cx+sizeX>0) {
						var sizeY = Math.round(currentMaster.h*objectRelativeSize);
						var py = this.screenHeight-this.defaultHorizon+(y - sizeY+1)-roadJolt;
						var cy = this.screenHeight-this.defaultHorizon+y-roadJolt;
					//	if (reuse || currentKeyframe < maxObjectDepth) {
							currentSprite.setVisible(false);
							currentSprite.setSpriteFromSceneryObject(currentMaster);
					//	}
					
						var pixelFrontLeftX = distanceToViewCenter*zoomFactor/distance-.5*currentMaster.frontW*objectRelativeSize;
						var pixelFrontRightX = pixelFrontLeftX+currentMaster.frontW*objectRelativeSize;
						var pixelBackLeftX = (distanceToViewCenter+roadAngleX)*zoomFactor/(distance+this.distanceStep)-.5*currentMaster.frontW*playerReferenceZ/(distance+this.distanceStep);
						var pixelBackRightX = pixelBackLeftX+currentMaster.frontW*playerReferenceZ/(distance+this.distanceStep);
						currentSprite.moveSpriteTo(	cx, cy, -distance/160, objectRelativeSize, pixelFrontLeftX-pixelBackLeftX,
													pixelBackRightX-pixelFrontRightX, 0, rightWall, this.screenHeight, leftWall);
						/*
						currentObject.style.width = sizeX+"px";
						currentObject.style.height = sizeY+"px";
						currentObject.style.top = py+"px";
						currentObject.style.left = px+"px";
						currentObject.style.clip = "rect("+(py<0?(-py):0)+"px "
														   +(px+sizeX>rightWall?rightWall-px:sizeX)+"px "
														   +(py+sizeY>this.screenHeight?(this.screenHeight-py):sizeY)+"px "
														   +(px<leftWall?(leftWall-px):0)+"px)";
						currentObject.style.zIndex = -Math.round(distance/160);
						*/
						
//						if (!reuse) {
							currentSprite.setVisible(true);
//						}
						//debugText += "plot de taille "+objectRelativeSize+" à "+px+" "+py+"<br>";
					} else {
						currentSprite.setVisible(false);
					}
				} else {
					currentSprite.setVisible(false);
				}
				++currentObjectIndex;
				currentSprite = this.scenerySprites[currentObjectIndex];

				if (obstacleRight.type >= 0) {
					
					var currentMaster = this.road.sceneryMaster[obstacleRight.type];
					var distanceToViewCenter = obstacleRight.x-cameraX-lateralOffset;
					var sizeX = Math.round(currentMaster.frontW*objectRelativeSize);
					var px = centerX+Math.round(distanceToViewCenter*zoomFactor/distance-sizeX/2);
					var cx = centerX+Math.round(distanceToViewCenter*zoomFactor/distance);
					if (px<this.screenWidth && cx+sizeX>0) {
						var sizeY = Math.round(currentMaster.h*objectRelativeSize);
						var py = this.screenHeight-this.defaultHorizon+(y - sizeY+1)-roadJolt;			
						var cy = this.screenHeight-this.defaultHorizon+y-roadJolt;
						//if (reuse || currentKeyframe < maxObjectDepth) {
							currentSprite.setVisible(false);
							currentSprite.setSpriteFromSceneryObject(currentMaster);
						//}
						
						var pixelFrontLeftX = distanceToViewCenter*zoomFactor/distance-.5*currentMaster.frontW*objectRelativeSize;
						var pixelFrontRightX = pixelFrontLeftX+currentMaster.frontW*objectRelativeSize;
						var pixelBackLeftX = (distanceToViewCenter+roadAngleX)*zoomFactor/(distance+this.distanceStep)-.5*currentMaster.frontW*playerReferenceZ/(distance+this.distanceStep);
						var pixelBackRightX = pixelBackLeftX+currentMaster.frontW*playerReferenceZ/(distance+this.distanceStep);
						currentSprite.moveSpriteTo(	cx, cy, -distance/160, objectRelativeSize, pixelFrontLeftX-pixelBackLeftX,
													pixelBackRightX-pixelFrontRightX, 0, rightWall, this.screenHeight, leftWall);
						/*
						currentObject.style.width = sizeX+"px";
						currentObject.style.height = sizeY+"px";
						currentObject.style.top = py+"px";
						currentObject.style.left = px+"px";
						currentObject.style.clip = "rect("+(py<0?(-py):0)+"px "
														   +(px+sizeX>rightWall?rightWall-px:sizeX)+"px "
														   +(py+sizeY>this.screenHeight?(this.screenHeight-py):sizeY)+"px "
														   +(px<leftWall?(leftWall-px):0)+"px)";
						currentObject.style.zIndex = -Math.round(distance/160);
						*/
						//if (!reuse) {
							currentSprite.setVisible(true);
						//}
						//debugText += "plot de taille "+objectRelativeSize+" à "+px+" "+py+"<br>";
					} else {
						currentSprite.setVisible(false);
					}
				} else {
					currentSprite.setVisible(false);
				}			
			}
			
			// Affichage des plans météo
			// Un plan météo contient le graphisme lié à un aléa climatique (brouillard, pluie, neige ...)
			// Il est partiellement transparent, ce qui laisse entrevoir ce qu'il y a derrière. Les plans
			// se composent, ce qui rend d'autant plus difficile de voir loin (c'est le but !)
			//
			// Un plan météo fait toute la largeur de l'image, et va du ciel (haut de l'image) jusqu'au sol
			// à la profondeur correspondante. Il est situé sur une keyframe.
			//
			// Un mécanisme de buffer circulaire est en place, comme pour les images du décor.
			if ((currentKeyframe&1) && (((currentKeyframe-keyframeBeforePlayerIndex)>>1)<this.weatherPlaneCount)) {
				var planeIndex = (currentKeyframe>>1)%this.weatherPlaneCount;
				var planeStyle = this.weatherPlane[planeIndex].style;
				
				if (currentWeatherAreaIndex+1 < this.road.weatherArea.length) {
					if (this.road.weatherArea[currentWeatherAreaIndex+1].offset < currentKeyframe) {
						++currentWeatherAreaIndex;
					}
				}
				var currentWeather = this.road.weatherArea[currentWeatherAreaIndex];

				if (currentWeather.opacity==0) {
					planeStyle.visibility="hidden";
				} else {
					var py = this.screenHeight-this.defaultHorizon+y-roadJolt;
					py = (py>this.screenHeight ? this.screenHeight : py);
					planeStyle.height=py+"px";
					
					// profondeur : on évite de passer devant la voiture du joueur et d'obscurcir celle-ci
					var depth = -Math.round(distance/160);
					depth = (depth>this.playerCarZIndex ? this.playerCarZIndex-1 : depth);
					planeStyle.zIndex=depth;
					
					planeStyle.backgroundColor = currentWeather.color;
					planeStyle.opacity=currentWeather.opacity;
					planeStyle.visibility="visible";
				}
			}
		
			previousAltitude = altitude;
			previousLateralOffset = lateralOffset;
		
			distance+=this.distanceStep;
			++currentKeyframe;
		}
		
		// Affichage du sprite du joueur : on tient compte de l'angle de la caméra
		var playerSpriteIndex = this.cameraView == 1 ? 0 : Math.round(this.playerAngle);
		if (this.cameraView == 2) {
			var rawAngle = this.normalizeAngle(this.speedDirection-this.playerHeading*3.1416/36, 0);
			playerSpriteIndex = Math.round(rawAngle*36/3.1416);
		} else if (this.cameraView == 3) {
			playerSpriteIndex = Math.round(-this.customCameraAngle);
		}
		
		// traduction de l'angle (1u=5°) en index de sprite, sachant que ceux-ci sont 
		//  - tous les 5° entre -35° et +35° (deux lignes du haut, avec ou sans les feux stop)
		//  - tous les 10° sur le reste (deux lignes du bas)
		if (playerSpriteIndex>-8 && playerSpriteIndex<8) { // entre -35° et +35° 
			this.playerSprite.setAnimationFrame(playerSpriteIndex+7, this.controlBrake ? 1 : 0);
		} else { // angles de vue inhabituels, mode drift généralement
			playerSpriteIndex = playerSpriteIndex>>1;
			var relativeFrame = (playerSpriteIndex>0 ? playerSpriteIndex-4 : playerSpriteIndex+32);
			var frameX = relativeFrame%15;
			var frameY = 2+(relativeFrame-frameX)/15;
			this.playerSprite.setAnimationFrame(frameX, frameY);
		}
		
		
		// on accorde l'image contenant le ciel et l'horizon
		if (this.playerZ <= this.road.finishLine) {
			var horizonAngle = 3.1416/36*(this.roadHeading+(this.cameraView == 1 ? -this.playerAngle : 0));
			if (this.cameraView == 2) {
				horizonAngle = this.speedDirection;
			}
			horizonAngle = this.normalizeAngle(horizonAngle, 0)*36/3.1416;
			// largeur du fond 1728 px, pour un tour à 72u=360, soit 1u=24px du fond
			// largeur calculée pour que l'horizon suive la route : 24 = zoomFactor[240000]/(distanceStep[500]*rapport playerAngle/roadAngle[20])
			var horizonX = Math.round(-624+24*horizonAngle);
			this.horizonPicture.style.backgroundPosition=horizonX+"px "+(-horizonLine)+"px";
			if (this.road.horizon.reflection) {
				this.secondPlanePicture.style.top=(this.screenHeight-currentLineIndex)+"px";
				this.secondPlanePicture.style.height=currentLineIndex+"px";
				this.secondPlanePicture.style.backgroundPosition=horizonX+"px "+(290-horizonLine-this.screenHeight-currentLineIndex)+"px";
			}
		}

		// On cache les lignes raster inutilisées (celles au-dessus de l'horizon)
		// Elles sont présentes, mais en visibilité hidden, donc non dessinées
		while (currentLineIndex<this.lineCount) {
			this.roadLines[currentLineIndex].style.visibility="hidden";
			++currentLineIndex;
		}

		// On cache aussi les voitures adverses au-delà de l'horizon visible
		while (currentOpponent<this.opponentCount) {
			this.carSprites[currentOpponent].setVisible(false);
			//debugText+="opponent at "+this.opponentData[currentOpponent].z+" beyond horizon. Speed="+this.opponentData[currentOpponent].speed+"<br>";
			++currentOpponent;
		}
		
		// On cache également les objets non utilisés (entre la dernière keyframe affichée et le début du buffer)
		while (currentKeyframe<keyframeBeforePlayerIndex+maxObjectDepth) {
			var currentObjectIndex = 2*(currentKeyframe%maxObjectDepth);
			this.scenerySprites[currentObjectIndex].setVisible(false);
			this.scenerySprites[currentObjectIndex+1].setVisible(false);
			++currentKeyframe;
		}
		
		// si on a ouvert un tunnel, on le referme
		// surtout pour les tunnels longs, sinon il manquerait l'intérieur
		// comme on ne connait pas la longueur réelle, donc la position de la sortie,
		// on le dessine totalement opaque (i.e. fermé)
		if (insideTunnel) {	
				var currentSection = this.road.groundSection[currentRoadSectionIndex];
				var currentTunnel = this.tunnelSprites[currentTunnelIndex];
				var cy = this.screenHeight-this.defaultHorizon+y-roadJolt;
				var exitHeight = Math.round(currentSection.tunnelInnerHeight*playerReferenceZ/distance);
				var topW = Math.max(cy-tunnelEntranceTop-exitHeight, 0);
				currentTunnelIndex++;
				currentTunnel.style.left = tunnelEntranceLeft+"px";
				currentTunnel.style.top = tunnelEntranceTop+"px";
				currentTunnel.style.width = "0px";
				currentTunnel.style.height = exitHeight+"px";
				currentTunnel.style.borderWidth = topW+"px "+(this.screenWidth-tunnelEntranceLeft)+"px 0px 0px";
				currentTunnel.style.borderColor = currentSection.tunnelInnerColor;
				currentTunnel.style.zIndex=-125;
				currentTunnel.style.visibility="visible";
		}
		// et on cache les divs "tunnel" qui n'ont pas servi
		while (currentTunnelIndex<this.tunnelSpriteCount) {
			this.tunnelSprites[currentTunnelIndex].style.visibility="hidden";
			++currentTunnelIndex;
		}

		
		this.debugScreen.innerHTML=debugText;
	
	} ,
	
	/**
	  * Effectue une étape de mouvement
	  * Prend en compte les contrôles (frein, virage, ..) activés
	  * Déplace la  voiture du joueur de n sur la courbe de la route
	  * Recalcule l'angle de la portion courante de route par rapport au nord
	  *
	  * Le calcul tient compte des différentes portions de routes (plusieurs keyframes)
	  * rencontrées durant le déplacement.
	  */
	moveOneStep : function()
	{
		var maxTorque = this.playerCar.maxTorque;
		var torqueLoss = this.playerCar.torqueLoss;
		var maxTorqueRPM = this.playerCar.torquePeak;
		
		var engineRunningFriction = 0; // 0.0000004;
		var engineIdleFriction = 0.000002;
		var airFriction = 0.000008;
		var weight = this.playerCar.weight;
		
		
		if (this.raceEnded) {
			this.controlGas = false;
			this.controlLeft = false;
			this.controlRight = false;
			this.controlBrake = true;
		}
		// Actions du joueur et maniement de la voiture
		// ralenti moteur à bas régime : carburation minimale
		var idle = 100*Math.exp(-0.006*this.engineRPM);
		this.engineRPM+=idle;
		var gearRatio = 1.0/(this.playerCar.gearRatio[this.currentGear]*150.0);
		// déperdition d'énergie du moteur
		if (this.currentGear > 0) {
			// plus faible en prise, pour tenir compte de l'inertie de la voiture
			this.engineRPM-=engineRunningFriction*this.engineRPM*this.engineRPM;
		} else {
			this.engineRPM-=engineIdleFriction*this.engineRPM*this.engineRPM;
		}
		
		var speedBeforeUserControl = 0;
		if (this.currentGear > 0) {
			speedBeforeUserControl = this.engineRPM*gearRatio;
		}
		
		if (this.controlGas) {
			// Accélération  : on augmente le régime moteur après calcul du couple
			// Si on est au point mort en boîte auto on passe la première
			var torque = maxTorque-torqueLoss*(this.engineRPM-maxTorqueRPM)*(this.engineRPM-maxTorqueRPM);			
			if (this.currentGear == 0 && this.automaticDrive && this.raceStarted) {			
				this.currentGear = 1;
				gearRatio = 1.0/(this.playerCar.gearRatio[1]*150.0);
			}
			this.engineRPM += 0.004*torque/(weight*gearRatio*gearRatio);
		}
		if (this.currentGear > 0) {
			this.playerSpeed = this.engineRPM*gearRatio;
		}
		var accelerationEffort = this.playerSpeed-speedBeforeUserControl;
		var brakeEffort = 0;
		
		if (this.controlBrake) {
			// Freinage constant. TODO améliorer en fonction des conditions, de la vitesse ..
			brakeEffort = 1;
			this.playerSpeed -= brakeEffort;
			if (this.playerSpeed < 0) {
				this.playerSpeed = 0;
			}
		}
		
		// frottement de l'air
		var deltaSpeed = -airFriction*this.playerSpeed*this.playerSpeed;
		var roadWidth = 0.85;
		// ralentissement supplémentaire en roulant en dehors de la route
		this.offRoad = (this.playerX>this.road.groundSection[this.roadSectionIndex].rightWidth-0.15 
					 || this.playerX<0.15-this.road.groundSection[this.roadSectionIndex].leftWidth);
		if (this.offRoad) {
			deltaSpeed -= (1.0-this.road.groundSection[this.roadSectionIndex].gripOutside)*0.02*this.playerSpeed;
		}
		var roadGrip = (this.offRoad ? this.road.groundSection[this.roadSectionIndex].gripOutside
						: this.road.groundSection[this.roadSectionIndex].gripInside);
						
		// changement d'énergie potentielle liée à l'altitude
		var keyframe = Math.floor(this.playerZ / this.distanceStep);
		var deltaAltitude = this.road.roadAltitude[(keyframe+1)%this.road.roadLength]-this.road.roadAltitude[keyframe]; 
		//deltaSpeed += -0.001*deltaAltitude*this.playerSpeed; // faux au changement de keyframe, mais suffisant
		this.playerSpeed +=deltaSpeed;
		if (this.currentGear > 0) {
			this.engineRPM = this.playerSpeed/gearRatio;
		} else {
			this.engineRPM -= 0.3*(this.engineRPM-800);
		}
	
	
		// Déplacement de la voiture du joueur
		// On avance proportionnellement à la vitesse
		// Et l'inertie de la voiture nous fait dévier dans les courbes
		var keyframeDistance = this.distanceStep*keyframe;
		var current = this.playerZ;
		this.formerPlayerZ = this.playerZ;
		this.playerZ+=this.playerSpeed<0 ? 0 : this.playerSpeed*5.5; // Z en cm, v en m/s : *100 pour m->cm, /25 car 25 fps 
		var addedAngle = 0;
		
		while (keyframeDistance < this.playerZ) {
			var angle = this.road.roadAngle[(keyframe+1)%this.road.roadLength];
			if ((keyframeDistance+this.distanceStep) < this.playerZ) {
				addedAngle += angle*(keyframeDistance+this.distanceStep-current)/this.distanceStep;
			} else {
				addedAngle += angle*(this.playerZ-current)/this.distanceStep;
			}
			
			keyframeDistance += this.distanceStep;
			keyframe = (keyframe+1)%this.road.roadLength;
			current = keyframeDistance;
		} 
		addedAngle*=20;
		this.roadHeading+=addedAngle;
		
		// déplacements latéraux : selon le mode en cours
		this.moveModeSpecific(addedAngle, accelerationEffort, brakeEffort); // fonction remappée sur une des trois suivantes (moveArcade, moveSimulation, moveDrift)
		
		this.moveOpponentCars();
		this.testDoors();
		this.detectCollisions();

		// projection de la voiture sur choc
		this.playerX+=this.lateralSpeed;
		this.lateralSpeed*=0.6;
		if (Math.abs(this.lateralSpeed) < 0.01) {
			this.lateralSpeed = 0;
		}

		
		// Adaptation du régime moteur, et boîte automatique
		if (this.playerSpeed <= 0) {
			this.playerSpeed = 0;
			this.currentGear = 0;
			this.engineRPM = 800;
		} else if (this.automaticDrive) {
			this.engineRPM = this.playerSpeed/gearRatio;
			if (this.currentGear>1) {
				if (this.engineRPM<1200 
					|| (this.playerSpeed < this.playerCar.shiftingSpeed[this.currentGear-1] && this.controlGas)) {
					--this.currentGear;
					this.engineRPM = this.playerSpeed*150.0*this.playerCar.gearRatio[this.currentGear];
				}
			}
			if ((this.playerSpeed > this.playerCar.shiftingSpeed[this.currentGear] && this.currentGear < this.playerCar.topGear)
				|| (this.playerSpeed>2 && this.currentGear==0)) {
				++this.currentGear;
				this.engineRPM = this.playerSpeed*150.0*this.playerCar.gearRatio[this.currentGear];
			}
		}
	},
	
	/**
	 * Effectue les traitements suivants, spécifiques au mode de jeu 'arcade'
	 *  - gestion des commandes latérales
	 *  - effet sur le déplacement latéral (X) de la voiture et la trajectoire
	 * En mode arcade, l'angle de la voiture par rapport à la route est une fonction linéaire
	 * de la direction imprimée à l'instant t par l'utilisateur, avec un léger effet d'inertie
	 * destiné à ce que la voiture aille progressivement de gauche à droite et pas d'un seul coup.
	 *
	 * Paramètres :
	 *  - addedAngle : le delta d'angle de la route correspondant au déplacement, utilisé
	 *    pour calculer la dérive latérale.
	 */
	moveArcade : function(bendAngle, accelEffort, brakeEffort)
	{
		// commande par l'utilisateur : l'appui sur gauche ou droite définit une valeur cible
		// pour l'angle de la voiture par rapport à la route
		var targetPlayerOrientation = 0.0;
		if (this.controlLeft) {
			targetPlayerOrientation = -3;
		}
		if (this.controlRight) {
			targetPlayerOrientation = 3;
		}
		
		// la voiture tourne ensuite de manière continue pour atteindre cette valeur cible,
		// plus ou moins vite en fonction de sa vitesse linéaire.
		// Ensuite, la voiture est décalée latéralement (en X) proportionnellement à cet angle.
		var deltaAngle = targetPlayerOrientation-this.playerAngle;
		var rotationStep =  0.12*Math.sqrt(this.playerSpeed);
		if (rotationStep > Math.abs(deltaAngle)) 
			rotationStep = Math.abs(deltaAngle);
		this.playerAngle += (deltaAngle > 0 ? rotationStep : -rotationStep);
		this.playerX += 0.0004*this.playerAngle*this.playerSpeed;

		// mode arcade : on fait juste dériver la voiture proportionnellement à l'angle du virage
		// avec une dérive augmentée en accélération et réduite au freinage comme dans Lotus
		var slideCoef = (this.controlGas ? this.skidAccel : (this.controlBrake ? this.skidBrake : this.skidStatic));
		this.playerX+=slideCoef*bendAngle*Math.sqrt(this.playerSpeed)/20;

	},
	
	/**
	 * Effectue les traitements suivants, spécifiques au mode de jeu 'simulation'
	 *  - gestion des commandes latérales
	 *  - effet sur l'angle de la voiture par rapport à la route
	 */
	moveSimulation : function(bendAngle, accelEffort, brakeEffort)
	{
		// TODO : introduire la maniabilité dans l'angle de rotation
		// Angle de rotation proportionnel à la racine carrée de la vitesse jusqu'à V0
		// Inversement proportionnelle à la vitesse ensuite.
		// La maniabilité maxi est atteinte à V0 ~ 20 m/s (70 km/h)
		var rotationStep = 0.12*Math.sqrt(this.playerSpeed);
		var maxRotation = 2.5/Math.sqrt(1.0+this.playerSpeed);
		rotationStep = (rotationStep > maxRotation ? maxRotation : rotationStep);
		var deltaAngle = 0;
		if (this.controlLeft)
		{
			deltaAngle =- rotationStep;
		}
		if (this.controlRight)
		{
			deltaAngle = rotationStep;
		}
		
		// l'expression de playerAngle est en relatif par rapport à la route, donc
		// on lui applique les virages.
		// L'utilisation d'une "fonction énergie" permet de contraindre l'angle dans
		// ]-18, +18[, ce qui correspond à ]-90°, +90°[, sans mettre une limitation
		// brutale à la borne, qui serait perçue en jeu comme un mur
		// ancien code sans la limite : this.playerAngle += bendAngle;
		// bendAngle (this.roadHeading) et this.playerAngle ont été ramenés à la même échelle 1u = 5°
		var coef = 3.1416/36;
		var energy = Math.tan(coef*this.playerAngle)+coef*(bendAngle+deltaAngle);
		this.playerAngle = Math.atan(energy)/coef;
		this.playerX += 0.0005*this.playerAngle*this.playerSpeed;
	},
	
	/**
	 * Effectue les traitements suivants, spécifiques au mode de jeu 'drift'
	 *  - gestion des commandes latérales
	 *  - effet sur l'angle de la voiture par rapport à la route
	 */
	moveDrift : function(bendAngle, accelEffort, brakeEffort)
	{
		var rotationStep = 0.04*Math.sqrt(this.playerSpeed);
		var maxRotation = 2.5/Math.sqrt(1.0+this.playerSpeed);
		rotationStep = (rotationStep > maxRotation ? maxRotation : rotationStep);
		var deltaAngle = 0;
		if (this.controlLeft)
		{
			deltaAngle =- rotationStep;
		}
		if (this.controlRight)
		{
			deltaAngle = rotationStep;
		}

		
		
		// décrochage des roues avant -> sous-virage
		if (this.frontLeftSkid && this.frontRightSkid) {
			deltaAngle/=5;
		}
				
				
		this.angularSpeed += deltaAngle; 
		if (this.playerSpeed <= 0) {
			brakeEffort = 0; // actionner les freins à l'arrêt est sans effet sur l'équilibre de la voiture
			this.angularSpeed = 0; // on ne peut pas tourner à l'arrêt
		}

		// on soustrait les approximations communes dédiées aux autres modes de jeu
		this.playerZ-=4*this.playerSpeed;
		this.playerSpeed -= accelEffort-brakeEffort;
		
		
		// l'expression de playerAngle est en relatif par rapport à la route, donc
		// on lui applique les virages.
		this.playerAngle += bendAngle+this.angularSpeed;
		this.playerHeading -= this.angularSpeed;
		
		// possibilité : pas de damping si les roues arrières patinent
		var angularDamping = 0.6 + (this.rearLeftSkid ? 0.2 : 0) + (this.rearRightSkid ? 0.2 : 0);
		this.angularSpeed *= angularDamping;
		
		// patinage des roues avant -> pas d'accélération sur une traction
		if (this.frontLeftSkid && this.frontRightSkid && this.playerCar.drive == 'F') {
			accelEffort *= 0.1;
		}
		
		// recalcul du centre de masse instantané
		var instantWeightX = 0.07*deltaAngle*this.playerSpeed;
		this.weightOffsetX = 0.9*this.weightOffsetX+0.1*instantWeightX;
		this.weightOffsetX = (this.weightOffsetX<-1 ? -1 : (this.weightOffsetX>1 ? 1 : this.weightOffsetX));
		var instantWeightY = -2*(accelEffort-brakeEffort);
		this.weightOffsetY = 0.8*this.weightOffsetY+0.2*instantWeightY;
		this.weightOffsetY = (this.weightOffsetY<-1 ? -1 : (this.weightOffsetY>1 ? 1 : this.weightOffsetY));
		
		var skidLimit = 100;
		this.frontLeftSkid = (this.playerCar.weight*(1-this.weightOffsetX)*(1+this.weightOffsetY)) < 4*skidLimit;
		this.frontRightSkid = (this.playerCar.weight*(1+this.weightOffsetX)*(1+this.weightOffsetY)) < 4*skidLimit;
		this.rearLeftSkid = (this.playerCar.weight*(1-this.weightOffsetX)*(1-this.weightOffsetY)) < 4*skidLimit;
		this.rearRightSkid = (this.playerCar.weight*(1+this.weightOffsetX)*(1-this.weightOffsetY)) < 4*skidLimit;

		var radianHeading = this.playerHeading*3.1416/36;
		var speedX = this.playerSpeed*Math.cos(this.speedDirection) + accelEffort*Math.cos(radianHeading);
		var speedY = this.playerSpeed*Math.sin(this.speedDirection) + accelEffort*Math.sin(radianHeading);
		this.playerSpeed = Math.sqrt(speedX*speedX+speedY*speedY) - brakeEffort;
		var realignFactor =0;
		if (this.playerSpeed <= 0) {
			this.playerSpeed = 0;
			this.speedDirection = radianHeading;
		} else {
			this.speedDirection = Math.atan2(speedY, speedX);
			
			// on realigne avec la direction des roues, pas celle de la voiture (TODO lors d'un dérapage uniquement)
			var tireRadianHeading = radianHeading; // + (this.controlLeft ? -0.2 : 0) + (this.controlRight ? 0.2 : 0);
			
			// realignement de la voiture : meilleur en accélération
			// TODO : considérer le centre de masse instantané et la différence de comportement FWD vs RWD
			tireRadianHeading = this.normalizeAngle(tireRadianHeading, this.speedDirection);
			//realignFactor = 0.05-0.06*this.weightOffsetY+(this.controlGas ? (this.playerCar.drive == 'F' ? 0.03 : (this.playerCar.drive == 'R' ? -0.03 : 0)) : 0);
			realignFactor = (this.playerSpeed>3 ? 3/this.playerSpeed : 1);
			var realign = realignFactor*(tireRadianHeading-this.speedDirection);
			
			// à chaque frame, on ne peut pas redresser de plus qu'une limite dépendant de la vitesse
			var realignMax = (this.controlGas ? 0.09 : 0.06); 
			var rotationMax = 0.015*Math.sqrt(this.playerSpeed);
			realignMax = (realignMax>rotationMax ? rotationMax : realignMax);
			realign = (realign>realignMax ? realignMax : (realign<-realignMax ? -realignMax : realign));
			this.speedDirection+=realign;
			//this.speedDirection = (1-realignFactor)*this.speedDirection+realignFactor*tireRadianHeading;
		}

		var speedToRoadAngle = this.speedDirection-(3.1416/36)*this.roadHeading;
		this.playerX -= (4/500)*Math.sin(speedToRoadAngle)*this.playerSpeed;
		this.playerZ += 4*Math.cos(speedToRoadAngle)*this.playerSpeed;

		var debugText = "Position X = "+(0.01*Math.round(100*this.playerX))+", Z = "+Math.round(this.playerZ);
		debugText+= "<br>weightY = "+(Math.round(100*this.weightOffsetY));
		debugText+="<br>Acceleration :  "+(Math.round(100*accelEffort))+"/100, brake = "+(Math.round(100*brakeEffort))+"/100";
		debugText+="<br>Speed : X "+((Math.round(100*speedX)))+"/100, Y "+((Math.round(100*speedY)))+"/100";
		debugText+=", Angular "+((Math.round(1000*this.angularSpeed)))+"/1000, Direction "+((Math.round(1000*this.speedDirection)))+"/1000 rad ("+(Math.round(180*this.speedDirection/3.1416))+"°)";
		debugText+="<br>Car heading : "+((Math.round(1000*this.playerHeading)))+"/1000 u ("+(Math.round(5*this.playerHeading))+"°), angle to road : "+((Math.round(1000*this.playerAngle)))+"/1000 u ("+(Math.round(5*this.playerAngle))+"°)";
		debugText+="<br>Road heading : "+((Math.round(1000*this.roadHeading)))+"/1000 u ("+(Math.round(5*this.roadHeading))+"°), angle with speed : "+((Math.round(1000*speedToRoadAngle)))+"/1000 rad ("+(Math.round(180*speedToRoadAngle/3.1416))+"°)";
		debugText+="<br>Facteur de réalignement : "+realignFactor;
		
		this.debugScreen.innerHTML=debugText;
	},

	
	/**
	 * Met à jour la cinématique du ghost (position X et Z, angle, vitesse)
	 * à partir de l'enregistrement fourni.
	 */
	updateGhost : function()
	{
		if (this.raceTime<=0) {
			// l'enregistrement démarre à t=0, pas avant
			this.ghostSpeed = 0;
			this.ghostZ = 1000; 
			this.ghostX = 0;
			this.ghostAngle = 0;
			return;
		}
		var frameLength = (this.controlsType==3 ? 4 : 3);
		var offset = frameLength*(this.raceTime>>2);
		
		// il se peut que l'enregistrement du ghost soit plus court, s'il a terminé la
		// course dans un chrono inférieur au temps actuel.
		// Dans ce cas on ne met plus à jour sa cinématique, il attend à l'arrivée
		if (this.ghostRecord.length > offset+2 && !this.raceEnded) 
		{
			var firstChar = this.ghostRecord.charCodeAt(offset)-45;
			var secondChar = this.ghostRecord.charCodeAt(offset+1)-45;
			var thirdChar = this.ghostRecord.charCodeAt(offset+2)-45;
			firstChar = firstChar - (firstChar>46 ? 1 : 0);
			secondChar = secondChar - (secondChar>46 ? 1 : 0);
			thirdChar = thirdChar - (thirdChar>46 ? 1 : 0);

			// Décodage spécifique par mode
			switch (this.controlsType) {
				case 1 : // simulation
					var recordedSpeed = firstChar + 80*(secondChar%10);
					var recordedDeltaX = ((secondChar-(secondChar%10))/10)+8*(thirdChar%4);
					var recordedDeltaAngle = (thirdChar-(thirdChar%4))/4;
					recordedDeltaX = 0.04*(recordedDeltaX-16);
					recordedDeltaAngle = 0.15*(recordedDeltaAngle-10);
					break;
				case 2 : // arcade
					var recordedSpeed = firstChar + 80*(secondChar%10);
					var recordedDeltaX = ((secondChar-(secondChar%10))/10)+8*(thirdChar%16);
					var recordedDeltaAngle = (thirdChar-(thirdChar%16))/16;
					recordedDeltaX = 0.005*(recordedDeltaX-64);
					recordedDeltaAngle -= 2;
					break;
				case 3 : // drift
					var fourthChar = this.ghostRecord.charCodeAt(offset+3)-45;
					fourthChar = fourthChar - (fourthChar>46 ? 1 : 0);
					var recordedSpeed = firstChar + 80*(secondChar%10);
					var recordedDeltaX = ((secondChar-(secondChar%10))/10)+8*(thirdChar%5);
					var recordedDeltaAngle = fourthChar;
					recordedDeltaX = 0.02*(recordedDeltaX-20);
					recordedDeltaAngle = 0.1*(recordedDeltaAngle-40);
					break;
			}
		
			// mesuré : dx [-0.32, +0.28], da [-1.05, +1.05] en simu
			this.ghostInstrumentationMinDX = (this.ghostInstrumentationMinDX < recordedDeltaX ? this.ghostInstrumentationMinDX : recordedDeltaX);
			this.ghostInstrumentationMaxDX = (this.ghostInstrumentationMaxDX > recordedDeltaX ? this.ghostInstrumentationMaxDX : recordedDeltaX);
			this.ghostInstrumentationMinDA = (this.ghostInstrumentationMinDA < recordedDeltaAngle ? this.ghostInstrumentationMinDA : recordedDeltaAngle);
			this.ghostInstrumentationMaxDA = (this.ghostInstrumentationMaxDA > recordedDeltaAngle ? this.ghostInstrumentationMaxDA : recordedDeltaAngle);
		
			this.ghostSpeed = 0.15*recordedSpeed;
			this.ghostZ += 4*this.ghostSpeed; 
			this.ghostX += recordedDeltaX;
			this.ghostAngle += recordedDeltaAngle;
			this.ghostLateralSpeed = recordedDeltaX;
		}
		else 
		{
			// freinage à la fin
			this.ghostSpeed = (this.ghostSpeed > 1 ? this.ghostSpeed-1 : 0);
			this.ghostZ += 4*this.ghostSpeed; 
		}
	},
	
	/**
	 * Effectue une étape (une frame) de rejeu
	 * Remplace moveOneStep() dans le cas du rejeu : on cale simplement la voiture
	 * sur les coordonnées du ghost, sans prise en compte des entrées clavier, du
	 * régime moteur ou des collisions
	 */
	replayOneStep : function()
	{
		if (this.currentGear>1) {
			if (this.engineRPM<1200 
				|| (this.playerSpeed < this.playerCar.shiftingSpeed[this.currentGear-1] && this.ghostSpeed>this.playerSpeed)) {
				--this.currentGear;
			}
		}
		if ((this.playerSpeed > this.playerCar.shiftingSpeed[this.currentGear] && this.currentGear < this.playerCar.topGear)
			|| (this.playerSpeed>2 && this.currentGear==0)) {
			++this.currentGear;
		}
		this.playerX = this.ghostX;
		this.formerPlayerZ = this.playerZ;
		this.playerZ = this.ghostZ;
		this.playerSpeed = this.ghostSpeed;
		this.playerAngle = this.ghostAngle;

		var instantSpeedDir = Math.atan2(-500*this.ghostLateralSpeed, this.ghostSpeed) + this.roadHeading*3.1416/36;
		this.speedDirection = 0.05*instantSpeedDir+0.95*this.speedDirection;
		this.playerHeading = this.roadHeading - this.playerAngle;
		if (this.currentGear>0) {
			this.engineRPM = this.playerSpeed*150.0*this.playerCar.gearRatio[this.currentGear];
		}
		
		// test des portes du slalom, uniquement pour réafficher le 2e chrono
		this.testDoors();
	},
	
	/**
	 * Effectue les opérations de télémétrie et d'enregistrement des paramètres de la course
	 * qui seront vérifiés puis stockés en base (côté serveur) pour resservir en tant que fantômes
	 * Les paramètres effectivement enregistrés dépendent du mode
	 */
	recordRace : function() 
	{
		this.recordModeSpecific(); // fonction remappée sur une des trois suivantes (recordRaceArcade, recordRaceSimulation, recordRaceDrift)
	},
	
	/**
	 * Effectue les opérations de télémétrie et d'enregistrement des paramètres de la course
	 * Les paramètres enregistrés en mode arcade sont : position/vitesse, position/vitesse latérale, angle
	 *
     * Le format : sur 3 caractères, 80 valeurs possibles ( de -, ASCII 45 à }, ASCII 125, privé de \, ASCII 92)
	 * (96 caractères entre ASCII 32 et 127, ', ", \ et + passent mal en Ajax/BDD. On a arrondi au multiple de 10 inférieur.)
	 * 3 champs : - vitesse sur 800 : [0, 800[ soit [0, 120[ m/s par pas de 0.15 m/s
	 *            - vitesse latérale sur [0, 128[ soit [-0.32, 0.32[ par pas de 0.005
	 *            - delta angle d'affichage sur 5 soit [-2, 2] par pas de 1
	 *  +-------------------------------+-------------------------------+-------------------------------+
	 *  |          80                   |     10           /      8     |      16             /   5     |
	 *  +-------------------------------+-------------------------------+-------------------------------+
	 *  |                       Vitesse                    |       Position                   | Angle   |
	 */	
	recordRaceArcade : function()
	{
		// Position / vitesse : On enregistre, non pas la vitesse réelle, mais le delta entre la position courante
		// et la position issue des enregistrements précédents (en fait l'intégrale des vitesses, 
		// car la position elle-même n'est pas enregistrée). Cela permet de réduire l'intervalle
		// des valeurs enregistrées, et de compenser sur plusieurs frames les erreurs d'arrondi
		var recordedSpeed = this.playerZ - this.recordedZ;
		recordedSpeed = Math.round(recordedSpeed/0.6); // max 799 -> 119.85 m/s soit 431.46 km/h
		recordedSpeed = (recordedSpeed < 0 ? 0 : (recordedSpeed > 799 ? 799 : recordedSpeed));
		this.recordedZ += 0.15*4*recordedSpeed; // facteur 4 issu de la conversion m/s -> cm (*100) sur une frame (/25)
		
		// De même, on mesure la variation de position latérale par rapport au dernier enregistrement
		var recordedDeltaX = this.playerX - this.recordedX;
		recordedDeltaX = Math.round(recordedDeltaX*200); // [-0.32, 0.32[ -> [-64, 64[
		recordedDeltaX = (recordedDeltaX < -64 ? -64 : (recordedDeltaX > 63 ? 63 : recordedDeltaX));
		this.recordedX += 0.005*recordedDeltaX;
		recordedDeltaX += 64; // [-64, 64[ -> [0, 128[ : on enregistre une valeur positive
		
		// Angle : calculé de même en variation par rapport au dernier affichage (frame du sprite calculée par arrondi)
		var recordedDeltaAngle = Math.round(this.playerAngle)-this.recordedAngle;
		recordedDeltaAngle = (recordedDeltaAngle < -2 ? -2 : (recordedDeltaAngle > 2 ? 2 : recordedDeltaAngle));
		this.recordedAngle += recordedDeltaAngle;
		recordedDeltaAngle += 2; // [-2, 2] -> [0, 4] :  on enregistre une valeur positive
		var firstChar = recordedSpeed%80;
		var secondChar = ((recordedSpeed-firstChar)/80)+((recordedDeltaX%8)*10);
		var thirdChar = ((recordedDeltaX-(recordedDeltaX%8))/8)+(recordedDeltaAngle*16);
			
		firstChar = firstChar+45+(firstChar>46?1:0); // on enlève \, ASCII 92
		secondChar = secondChar+45+(secondChar>46?1:0);
		thirdChar = thirdChar+45+(thirdChar>46?1:0);
		this.recording+=String.fromCharCode(firstChar, secondChar, thirdChar);
		
		
	},
	
	/**
	 * Effectue les opérations de télémétrie et d'enregistrement des paramètres de la course
	 * Les paramètres enregistrés en mode simulation sont position/vitesse, angle
	 *
     * Le format : sur 3 caractères, 80 valeurs possibles ( de -, ASCII 45 à }, ASCII 125, privé de \, ASCII 92)
	 * (96 caractères entre ASCII 32 et 127, ', ", \ et + passent mal en Ajax/BDD. On a arrondi au multiple de 10 inférieur.)
	 * 3 champs : - vitesse sur 800 : [0, 799[ soit [0, 120[ m/s par pas de 0.15 m/s
	 *            - vitesse latérale sur 32 : [0, 32[ soit [-0.64, 0.64[ par pas de 0.04
	 *            - delta angle d'affichage sur 16 : [0, 20[ soit [-1.5, 1.5[ par pas de 0.15
	 *  +-------------------------------+-------------------------------+-------------------------------+
	 *  |          80                   |     10           /      8     |      4     /       20         |
	 *  +-------------------------------+-------------------------------+-------------------------------+
	 *  |                       Vitesse                    |    Vitesse latérale     |       Angle      |
	 */	
	recordRaceSimulation : function()
	{
		// Position / vitesse : On enregistre, non pas la vitesse réelle, mais le delta entre la position courante
		// et la position issue des enregistrements précédents (en fait l'intégrale des vitesses, 
		// car la position elle-même n'est pas enregistrée). Cela permet de réduire l'intervalle
		// des valeurs enregistrées, et de compenser sur plusieurs frames les erreurs d'arrondi
		var recordedSpeed = this.playerZ - this.recordedZ;
		recordedSpeed = Math.round(recordedSpeed/0.6); // max 799 -> 119.85 m/s soit 431.46 km/h
		recordedSpeed = (recordedSpeed < 0 ? 0 : (recordedSpeed > 799 ? 799 : recordedSpeed));
		this.recordedZ += 0.15*4*recordedSpeed; // facteur 4 issu de la conversion m/s -> cm (*100) sur une frame (/25)
		
		// De même, on mesure la variation de position latérale par rapport au dernier enregistrement
		var recordedDeltaX = this.playerX - this.recordedX;
		recordedDeltaX = Math.round(recordedDeltaX*25); // [-0.64, 0.64[ -> [-16, 16[
		recordedDeltaX = (recordedDeltaX < -16 ? -16 : (recordedDeltaX > 15 ? 15 : recordedDeltaX));
		this.recordedX += 0.04*recordedDeltaX;
		recordedDeltaX += 16; // [-16, 16[ -> [0, 32[ : on enregistre une valeur positive
		
		// Angle : calculé de même en variation par rapport au dernier affichage (frame du sprite calculée par arrondi)
		var recordedDeltaAngle = Math.round(this.playerAngle)-this.recordedAngle;
		recordedDeltaAngle = Math.round(recordedDeltaAngle/0.15); // [-1.5, 1.5[ -> [-10, 10[
		recordedDeltaAngle = (recordedDeltaAngle < -10 ? -10 : (recordedDeltaAngle > 9 ? 9 : recordedDeltaAngle));
		this.recordedAngle += recordedDeltaAngle*0.15;
		recordedDeltaAngle += 10; // [-10, 10[ -> [0, 20[ :  on enregistre une valeur positive
		
		var firstChar = recordedSpeed%80;
		var secondChar = ((recordedSpeed-firstChar)/80)+((recordedDeltaX%8)*10);
		var thirdChar = ((recordedDeltaX-(recordedDeltaX%8))/8)+(recordedDeltaAngle*4);
		firstChar = firstChar+45+(firstChar>46?1:0); // on enlève \, ASCII 92
		secondChar = secondChar+45+(secondChar>46?1:0);
		thirdChar = thirdChar+45+(thirdChar>46?1:0);
		this.recording+=String.fromCharCode(firstChar, secondChar, thirdChar);
	},
	
	/**
	 * Effectue les opérations de télémétrie et d'enregistrement des paramètres de la course
	 * Les paramètres enregistrés en mode drift sont position/vitesse longitudinale et latérale, angle
	 *
     * Le format : sur 4 caractères, 80 valeurs possibles ( de -, ASCII 45 à }, ASCII 125, privé de \, ASCII 92)
	 * (96 caractères entre ASCII 32 et 127, ', ", \ et + passent mal en Ajax/BDD. On a arrondi au multiple de 10 inférieur.)
	 * 3 champs : - vitesse sur 800 : [0, 799[ soit [0, 120[ m/s par pas de 0.15 m/s
	 *            - vitesse latérale sur 40 : [0, 40[ soit [-0.40, 0.40[ par pas de 0.02
	 *            - delta angle d'affichage sur 80 : [0, 80[ soit [-4.0, 4.0[ par pas de 0.1
	 *  +-------------------------------+-------------------------------+-------------------------------+-------------------------------+
	 *  |          80                   |     10           /      8     |      5     /       16         |              80               |
	 *  +-------------------------------+-------------------------------+-------------------------------+-------------------------------+
	 *  |                       Vitesse                    |    Vitesse latérale     |       Spare      |              Angle            |
	 */	
	recordRaceDrift : function()
	{
		// Position / vitesse : On enregistre, non pas la vitesse réelle, mais le delta entre la position courante
		// et la position issue des enregistrements précédents (en fait l'intégrale des vitesses, 
		// car la position elle-même n'est pas enregistrée). Cela permet de réduire l'intervalle
		// des valeurs enregistrées, et de compenser sur plusieurs frames les erreurs d'arrondi
		var recordedSpeed = this.playerZ - this.recordedZ;
		recordedSpeed = Math.round(recordedSpeed/0.6); // max 799 -> 119.85 m/s soit 431.46 km/h
		recordedSpeed = (recordedSpeed < 0 ? 0 : (recordedSpeed > 799 ? 799 : recordedSpeed));
		this.recordedZ += 0.15*4*recordedSpeed; // facteur 4 issu de la conversion m/s -> cm (*100) sur une frame (/25)
		
		// De même, on mesure la variation de position latérale par rapport au dernier enregistrement
		var recordedDeltaX = this.playerX - this.recordedX;
		recordedDeltaX = Math.round(recordedDeltaX*50); // [-0.40, 0.40[ -> [-20, 20[
		recordedDeltaX = (recordedDeltaX < -20 ? -20 : (recordedDeltaX > 19 ? 19 : recordedDeltaX));
		this.recordedX += 0.02*recordedDeltaX;
		recordedDeltaX += 20; // [-20, 20[ -> [0, 40[ : on enregistre une valeur positive
		
		// Angle : calculé de même en variation par rapport au dernier affichage (frame du sprite calculée par arrondi)
		var recordedDeltaAngle = Math.round(this.playerAngle)-this.recordedAngle;
		recordedDeltaAngle = Math.round(recordedDeltaAngle*10.0); // [-4.0, 4.0[ -> [-40, 40[
		recordedDeltaAngle = (recordedDeltaAngle < -40 ? -40 : (recordedDeltaAngle > 39 ? 39 : recordedDeltaAngle));
		this.recordedAngle += recordedDeltaAngle*0.1;
		recordedDeltaAngle += 40; // [-40, 39] -> [0, 79] :  on enregistre une valeur positive
		
		var firstChar = recordedSpeed%80;
		var secondChar = ((recordedSpeed-firstChar)/80)+((recordedDeltaX%8)*10);
		var thirdChar = ((recordedDeltaX-(recordedDeltaX%8))/8);
		var fourthChar = recordedDeltaAngle;
		firstChar = firstChar+45+(firstChar>46?1:0); // on enlève \, ASCII 92
		secondChar = secondChar+45+(secondChar>46?1:0);
		thirdChar = thirdChar+45+(thirdChar>46?1:0);
		fourthChar = fourthChar+45+(fourthChar>46?1:0);
		this.recording+=String.fromCharCode(firstChar, secondChar, thirdChar, fourthChar);
	},
	
	/**
	 * Effectue le déplacement des voitures contrôlées par l'ordinateur
	 *  - changement de file
	 *  - adaptation de la vitesse
	 *  - déplacement effectif
	 */
	moveOpponentCars : function()
	{
	
		// déplacement des adversaires : changement de file pour maximiser
		// la distance de sécurité, en évitant les zones occupées
		var deltaT = 2;
		var roadLength = this.road.roadLength*this.road.distanceStep;
		for (var index = 0; index<this.opponentCount; ++index)
		{
			// identification de l'état des files : on étudie les positions
			// des voitures devant pour savoir quelles sont les zones qu'elles occupent
			// (immédiatement et dans un futur très proche) et slalomer entre.
			var laneCount = 7; // 4 files + 3 au milieu "à la brésilienne"
			var forbiddenZoneBegin = new Array(laneCount);
			var forbiddenZoneEnd = new Array(laneCount);
			var minimumSpeed = new Array(laneCount);
			for (var lane=0; lane<laneCount; ++lane) {
				forbiddenZoneBegin[lane] = 9999;
				forbiddenZoneEnd[lane] = -9999;
				minimumSpeed[lane] = 9999;
			}
			for (var loopIndex = 1; loopIndex<this.opponentCount ; ++loopIndex) {
				var otherIndex = (index+loopIndex)%this.opponentCount;
				var otherX = this.opponentData[otherIndex].x;
				var otherZ = (roadLength + this.opponentData[otherIndex].z
							- (this.opponentData[index].z%roadLength))%roadLength;
				var otherSpeed = this.opponentData[otherIndex].speed;
				var otherTargetX = this.opponentData[otherIndex].targetX;
				
				// on prend une marge de 0.14 pour la largeur de la voiture 
				// si celle-ci est à cheval sur deux files, on considère qu'elle occupe les deux
				var leftLane = Math.round(3+4*(otherX<=otherTargetX ? otherX-0.14 : otherTargetX));
				var rightLane = Math.round(3+4*(otherX>=otherTargetX ? otherX+0.14 : otherTargetX));
				
				var futureZ = otherZ + (otherSpeed - this.opponentData[index].speed)*deltaT;
				var beginZ = (futureZ < otherZ ? futureZ : otherZ);
				var endZ = (futureZ > otherZ ? futureZ : otherZ);
				
				for (var lane=leftLane ; lane <= rightLane && lane<laneCount ; ++lane) {
					if (endZ > forbiddenZoneEnd[lane]) {
						forbiddenZoneEnd[lane] = endZ;
					}
					if (beginZ < forbiddenZoneBegin[lane]) {
						forbiddenZoneBegin[lane] = beginZ;
						minimumSpeed[lane] = otherSpeed;
					}
				}
			}
			
			// choix de la file destination
			var currentLane = Math.round(3+4*this.opponentData[index].x);
			var targetLane = currentLane;
			var longestDistance = forbiddenZoneBegin[currentLane];
			var lane = currentLane;
			var leftLane = currentLane;
			while (lane > 0 && forbiddenZoneBegin[lane-1] >= longestDistance) {
				--lane;
				// on ne prend la file comme "meilleure" que dans le cas d'une différence
				// stricte, mais on continue à itérer dans le cas d'une égalité
				// (pour ne pas s'arrêter dans le cas d'une fonction en escalier)
				if (forbiddenZoneBegin[lane] > longestDistance) {
					longestDistance = forbiddenZoneBegin[lane];
					leftLane = lane;
				}
			}
			lane = currentLane;
			// on privilégie les files les plus à droite afin de forcer les voitures
			// à se rabattre : en cas d'égalité on prend la file de droite
			while (lane+1 < laneCount && forbiddenZoneBegin[lane+1] >= longestDistance) {
				longestDistance = forbiddenZoneBegin[lane+1];
				++lane;
			}
			if (lane == currentLane) { 
				lane=leftLane;
			}
			/*
			if (forbiddenZoneBegin[lane] >= forbiddenZoneBegin[currentLane]) {
				lane=currentLane;
			}
			*/
			this.opponentData[index].targetX = lane*0.25-0.75;
			
			var maxSpeedTargetLane = 0.5*(minimumSpeed[lane]+this.opponentData[index].speed);
			var maxSpeedCurrentLane = minimumSpeed[currentLane]+10;
			
			// Adaptation de la vitesse à la courbure des virages
			// Mini 10 m/s = 36 km/h même dans les virages les plus serrés
			var currentZ = this.opponentData[index].z;
			var nextKeyframe = Math.ceil(currentZ/this.distanceStep)%this.road.roadLength;
			var curve = Math.abs(this.road.roadAngle[nextKeyframe]);
			var targetSpeed = 40 - 10*curve; // un virage à 0.1 (pris à 108 km/h) est déja assez serré
			
			targetSpeed = (maxSpeedTargetLane < targetSpeed ? maxSpeedTargetLane : targetSpeed);
			targetSpeed = (maxSpeedCurrentLane < targetSpeed ? maxSpeedCurrentLane : targetSpeed);
			targetSpeed = (targetSpeed < 10 ? 10 : targetSpeed);
			this.opponentData[index].targetSpeed = targetSpeed;
			
			/*
			debugText+="Car "+index+" x="+this.opponentData[index].x+" -> "+this.opponentData[index].targetX+", speed ="+Math.round(this.opponentData[index].speed)+" -> "+Math.round(this.opponentData[index].targetSpeed)+" [";
			for (var i=0; i<laneCount ; ++i) { 
				debugText+=Math.round(forbiddenZoneBegin[i])+(i<laneCount-1?",":"]<br>");
			}
			*/
			
			// Déplacement du véhicule et freinage/accélération
			this.opponentData[index].z += this.opponentData[index].speed*4; // Z en cm, v en m/s : *100 pour m->cm, /25 car 25 fps 
			if (this.raceStarted) {
				var deltaSpeed = this.opponentData[index].targetSpeed - this.opponentData[index].speed;
				var incSpeed = deltaSpeed>0 ? 0.4 : -0.8; // en m/s/frame soit 36 et -72 km/h/s
				if (Math.abs(deltaSpeed)<Math.abs(incSpeed)) {
					incSpeed = deltaSpeed;
				}
				this.opponentData[index].speed += incSpeed;
			
				var deltaX = this.opponentData[index].targetX - this.opponentData[index].x;
				var realAngle = this.opponentData[index].angle*0.01;
				if (realAngle<deltaX && realAngle<0.03) {
					realAngle+=0.01;
				} else if (realAngle>deltaX && realAngle>-0.03) {
					realAngle-=0.01;
				} 
				if (Math.abs(deltaX)<Math.abs(realAngle)) {
					realAngle = deltaX;
				}
				this.opponentData[index].x += realAngle;
				this.opponentData[index].angle = realAngle*100;
			}
		}
		
		//On réordonne correctement les voitures par z croissant
		// Algo en o(N²) mais efficace dans le cas d'une liste "presque triée"
		var sortingDone = false;
		var givingUp = this.opponentCount+1;
		var roadLength = this.road.roadLength*this.distanceStep; // longueur de la route en m (et non plus en segments)
		var cutoffZ = (this.playerZ + 0.5*roadLength)%roadLength; // offset de rebouclage des voitures adversaires
		while (!sortingDone && givingUp>0) {
			sortingDone = true;
			for (var index = 0; index<this.opponentCount-1; ++index)
			{
				var nextIndex = (index+1)%this.opponentCount;
				// optimisation à étudier : cacher les valeurs relatives
				var currentZ = (this.opponentData[index].z + roadLength - cutoffZ)%roadLength;
				var nextZ = (this.opponentData[nextIndex].z + roadLength - cutoffZ)%roadLength;
				if (currentZ>nextZ) {
					var temp = this.opponentData[index];
					this.opponentData[index] = this.opponentData[nextIndex];
					this.opponentData[nextIndex] = temp;
					sortingDone = false;
				}
			}
			--givingUp;
			if (givingUp == 1 && !sortingDone) {
				givingUp = 1; // annoying condition. This line as an anchor for breakpoints
			}

		}
	},
	
	/**
	 * Mesure les distances afin de détecter les collisions :
	 *   - entre la voiture du joueur et les éléments du décor
	 *   - entre la voiture du joueur et les autres voitures.
	 * 
	 * En cas de collision, la voiture est ralentie et renvoyée latéralement à l'opposé de l'obstacle
	 * Si c'est avec une autre voiture, celle ci est également impactée du vecteur inverse.
	 * Si le joueur se fait percuter à l'arrière par une autre voiture, il accélèrera au lieu de ralentir.
	 * Les éléments du décor restent immobiles.
	 */
	detectCollisions : function()
	{
		var debugText="";
		
		// position de la prochaine keyframe
		var keyframe = Math.ceil(this.playerZ / this.distanceStep);
		var keyframeDistance = this.distanceStep*keyframe;
		keyframe = keyframe%this.road.roadLength;
		
		// le nombre de pixels correspondant une largeur de 1 unité de route
		// à la profondeur de la voiture du joueur
		var pixelWidthAtUnitDistance = 220;
	
		// Détection des collisions avec le décor
		if (this.playerZ + this.carLength >= keyframeDistance) {
			// rappel : on n'a d'objets que sur les keyframes
			// on mesure la distance latérale entre la voiture et l'objet du décor
			if (this.road.sceneryLeft[keyframe].type>=0) {
				var leftMaster = this.road.sceneryMaster[this.road.sceneryLeft[keyframe].type];
				var collisionDistance = 0.5*(this.carPixelWidth + leftMaster.collisionWidth)/pixelWidthAtUnitDistance;
				// centre de collision de l'objet (tronc d'un arbre, etc)
				var sceneryObjectX = this.road.sceneryLeft[keyframe].x + (-0.5*leftMaster.frontW + leftMaster.collisionLeft + 0.5*leftMaster.collisionWidth)/pixelWidthAtUnitDistance;;
				var hitPower = collisionDistance - Math.abs(this.playerX - sceneryObjectX);
				if (hitPower > 0) {
					//debugText += "boum, object at "+sceneryObjectX+", distance collision = "+collisionDistance+"<br>";
					switch (leftMaster.collisionEffect) {
						case 0 : // objet solide, collision normale
							if (this.playerX < sceneryObjectX) {
								this.lateralSpeed -= hitPower+collisionDistance;
							} else {
								this.lateralSpeed += hitPower+collisionDistance;
							} 
							this.playerSpeed *= (1-this.speedLossHittingScenery);
							break;
						case 1 : // objet léger, vole
							break;
						case 2 : // déclenchement d'une séquence d'objets bonus
							this.bonusCountInUse = true;
							break;
						case 3 : // objet bonus récupéré
							++this.bonusPointsObtained;
							break;
						default:
							break;
					}
				}
			}
			if (this.road.sceneryRight[keyframe].type>=0) {
				var rightMaster = this.road.sceneryMaster[this.road.sceneryRight[keyframe].type];
				var collisionDistance = 0.5*(this.carPixelWidth + rightMaster.collisionWidth)/pixelWidthAtUnitDistance;
				// centre de collision de l'objet (tronc d'un arbre, etc)
				var sceneryObjectX = this.road.sceneryRight[keyframe].x  + (-0.5*rightMaster.frontW + rightMaster.collisionLeft + 0.5*rightMaster.collisionWidth)/pixelWidthAtUnitDistance;;;
				var hitPower = collisionDistance - Math.abs(this.playerX - sceneryObjectX);
				if (hitPower > 0) {
					//debugText += "boum, object at "+sceneryObjectX+", distance collision = "+collisionDistance+"<br>";
					switch (rightMaster.collisionEffect) {
						case 0 : // objet solide, collision normale
							if (this.playerX < sceneryObjectX) {
								this.lateralSpeed -= hitPower+collisionDistance;
							} else {
								this.lateralSpeed += hitPower+collisionDistance;
							} 
							this.playerSpeed *= (1-this.speedLossHittingScenery);
							break;
						case 1 : // objet léger, vole
							break;
						case 2 : // déclenchement d'une séquence d'objets bonus
							this.bonusCountInUse = true;
							break;
						case 3 : // objet bonus récupéré
							++this.bonusPointsObtained;
							break;
						default:
							break;
					}
				}
			}
			
		}
		
		// Détection des collisions avec les murs du tunnel
		if (this.road.groundSection[this.roadSectionIndex].isTunnel) {
			var carHalfWidth = 64/pixelWidthAtUnitDistance;
			var overhangLeft = this.road.groundSection[this.roadSectionIndex].leftWidth+this.playerX-carHalfWidth;
			if (overhangLeft<0) {
				this.lateralSpeed -= 2*overhangLeft;
			}
			var overhangRight = this.road.groundSection[this.roadSectionIndex].rightWidth-(this.playerX+carHalfWidth);
			if (overhangRight<0) {
				this.lateralSpeed = 2*overhangRight;
			}
		}
		
		// Détection des collisions avec les autres voitures
		// Faiblesse de l'algo : il ne fait pas de prévisionnel, juste une constatation des positions relatives
		// En allant suffisamment vite (mini 2*carLength par frame, soit 720 km/h de différence), on peut traverser une
		// voiture adverse par effet tunnel
		for (var index = 0; index<this.opponentCount; ++index)
		{
			var deltaZ = this.opponentData[index].z - this.playerZ;
			var deltaX = this.opponentData[index].x - this.playerX;
			var collisionX = 0.5*(64+this.opponentData[index].collisionWidth)/pixelWidthAtUnitDistance;
			debugText+="test avec "+Math.round(this.opponentData[index].z)+", deltaZ="+Math.round(deltaZ)+", deltaX="+deltaX+"/"+collisionX;
			if (Math.abs(deltaZ)<this.carLength && Math.abs(deltaX)<collisionX) {
				// collision
				debugText+="<b>paf</b>";

				// on considère qu'il s'agit d'un choc frontal si les voitures n'étaient pas à la même hauteur au tour précédent
				var previousPlayerZ = this.playerZ-this.playerSpeed*4;
				var previousOpponentZ = this.opponentData[index].z - this.opponentData[index].speed*4;
				var frontalHit = (Math.abs(previousPlayerZ-previousOpponentZ)>this.carLength);
				var lateralStrength = Math.abs(deltaX) / collisionX; // 0 pour un choc frontal/arrière, 1 pour un choc latéral

				if (frontalHit) {
					// sur un choc frontal, on ajuste les vitesses en Z et les positions
					var averageSpeed = 0.5*(this.opponentData[index].speed + this.playerSpeed);
					var deltaSpeed = (1-lateralStrength)*0.5*Math.abs(this.playerSpeed-averageSpeed);
					if (this.opponentData[index].z < this.playerZ) {
						// le joueur se fait pousser
						this.playerSpeed = averageSpeed + deltaSpeed;
						this.opponentData[index].speed = averageSpeed - deltaSpeed;
						this.playerZ = this.opponentData[index].z+this.carLength;
					} else {
						// le joueur percute une autre voiture
						this.playerSpeed = averageSpeed - deltaSpeed;
						this.opponentData[index].speed = averageSpeed + deltaSpeed;
						this.opponentData[index].z = this.playerZ + this.carLength;
					}
				} else {
					lateralStrength*=3;
				}
				// on décale latéralement, deuxieme effet du choc (frontal ou latéral)
				this.lateralSpeed -= lateralStrength*0.2*deltaX; 
				this.opponentData[index].x+=lateralStrength*0.2*deltaX;
			}
			debugText+="<br>";
		}
		
		//this.debugScreen.innerHTML=debugText;
	},
	
	
	/**
	 * Vérifie le passage des portes du bon côté, à hauteur de la keyframe courante
	 * Le test n'est effectué que si une keyframe a effectivement été franchie dans le mouvement courant
	 * 
	 * Si un des éléments du décor correspondant à la keyframe est une porte (door!=0),
	 * la méthode vérifie si la voiture passe du bon côté
	 *  - à droite si door>0
	 *  - à gauche si door<0
	 *
	 * Si c'est une porte et qu'elle est passée du mauvais côté, une pénalité est ajoutée au timer secondaire
	 * Si c'est la porte de départ, le timer secondaire est démarré
	 * Si c'est la porte d'arrivée, le timer secondaire est arrêté
	 */
	testDoors : function()
	{
		var formerKeyframe = Math.floor(this.formerPlayerZ / this.distanceStep);
		var keyframe = Math.floor(this.playerZ / this.distanceStep);
		
		if (keyframe == formerKeyframe) {
			return;
		}
	
		// le nombre de pixels correspondant une largeur de 1 unité de route
		// à la profondeur de la voiture du joueur
		var pixelWidthAtUnitDistance = 220;
		
		this.doorMissed = false;
		this.doorCrossed = false;
		var productLeft = 0;
		var productRight = 0;
		var timingLine = 0;
		var obstacleLeft = this.road.sceneryLeft[keyframe%this.road.roadLength];
		if (obstacleLeft.type >= 0) {
			var currentMaster = this.road.sceneryMaster[obstacleLeft.type];
			if (currentMaster.door != 0) {
				var obstacleX = obstacleLeft.x-0.5*currentMaster.frontW/pixelWidthAtUnitDistance;
				productLeft = (this.playerX - obstacleX)*currentMaster.door;
			}
			timingLine = currentMaster.line; 
		}
		
		var obstacleRight = this.road.sceneryRight[keyframe%this.road.roadLength];
		if (obstacleRight.type >= 0) {
			var currentMaster = this.road.sceneryMaster[obstacleRight.type];
			if (currentMaster.door != 0) {
				productRight = (this.playerX - obstacleRight.x)*currentMaster.door;
			}
		}
		this.doorMissed = (productLeft<0) || (productRight<0);
		this.doorCrossed = ((productLeft>0) || (productRight>0)) && (!this.doorMissed);
		
		if (this.doorMissed) {
			this.secondTimerDelta -= this.missedDoorTimePenalty;
		}
		
		if (timingLine>0 && this.doorCrossed) {	// porte de départ franchie
			this.secondTimerInUse = true;
			this.secondTimerDelta = this.raceTime;
		}
		if (timingLine<0 && this.secondTimerInUse) { // ligne d'arrivée franchie (même ratée)
			this.secondTimerInUse = false;
		}
					
	},
	
	/** 
	 * Met à jour les différents affichages :
	 *  - vitesse
	 *  - rapport
	 *  - régime moteur (dessin RPM)
	 *  - temps écoulé ou décompte de départ
	 */
	updatePanels : function()
	{
		var currentTime = this.raceTime;
	
		this.imageFx.outlinedWrite(this.infoPanelSpeed, Math.round(this.playerSpeed*3.6)+" km/h");
		this.imageFx.outlinedWrite(this.infoPanelRPM, Math.round(this.engineRPM)+" rpm");
		this.infoPanelRPMBar.style.clip = "rect(0px "+Math.round(0.01*this.engineRPM)+"px 20px 0px)";
		this.imageFx.outlinedWrite(this.infoPanelGear, this.currentGear);
		
		var timeString = "";
		if (currentTime < 100) {
			if (currentTime>=-200 && currentTime<-100) {
				timeString = _("EN003");
			} else if (currentTime>=-100 && currentTime<0) {
				timeString = _("EN004");
			} else if (currentTime>=0) {
				timeString = _("EN005");
			}
		} else {
			timeString = this.formatRaceTime(currentTime);
		}
		this.raceStarted = (currentTime >= 0);
		this.imageFx.outlinedWrite(this.infoPanelTime, timeString);
		
		// affichage du second timer (course dans la course), et retour en noir au bout du délai imparti
		if (this.secondTimerInUse) {
			this.imageFx.outlinedWrite(this.infoPanelBonusText, this.formatRaceTime(currentTime-this.secondTimerDelta));
			if (this.doorMissed || this.doorCrossed) {
				this.imageFx.setHighlightColor(this.infoPanelBonusText, this.doorCrossed ? "#008000" : "#C00000");
				this.bonusTextColorReset = 25;
				this.doorMissed = this.doorCrossed = false;
			}
			if (this.bonusTextColorReset > 0) {
				--this.bonusTextColorReset;
				if (this.bonusTextColorReset == 0) {
					this.imageFx.setHighlightColor(this.infoPanelBonusText, "black");
				}
			}
		}
		// affichage du nombre de bonus récupérés : vert si le nombre cible est atteint, noir sinon
		if (this.bonusCountInUse) {
			this.imageFx.outlinedWrite(this.infoPanelBonusText, this.bonusPointsObtained+"/"+this.bonusPointsTarget);
			this.imageFx.setHighlightColor(this.infoPanelBonusText, this.bonusPointsTarget >= this.bonusPointsObtained ? "#008000" : "black");
		}
		
		// affichage du centre de masse
		this.xBalance.style.left = Math.round(10+10*this.weightOffsetX)+"px";
		this.yBalance.style.top = Math.round(20-20*this.weightOffsetY)+"px";
		this.frontLeftSkidPanel.style.visibility = (this.frontLeftSkid ? "visible" : "hidden");
		this.frontRightSkidPanel.style.visibility = (this.frontRightSkid ? "visible" : "hidden");
		this.rearLeftSkidPanel.style.visibility = (this.rearLeftSkid ? "visible" : "hidden");
		this.rearRightSkidPanel.style.visibility = (this.rearRightSkid ? "visible" : "hidden");
	},
	
	
	/**
	 * Fait scroller le texte d'information
	 * utilisé pendant la démo ou la pause.
	 */
	scrollDemoText : function(timeIndex)
	{
		var scrollOffset = timeIndex%75;
		if (scrollOffset<25) {
			var x = Math.round(this.screenWidth*(24-scrollOffset)/24);
			this.demoText.style.left=x+"px";
			this.demoText.style.clip="rect(0px "+(this.screenWidth-x)+"px auto 0px)";
		}
		if (scrollOffset>50) {
			var x = Math.round(-this.screenWidth+this.screenWidth*(75-scrollOffset)/24);
			this.demoText.style.left=x+"px";
			this.demoText.style.clip="rect(0px auto auto "+(-x)+"px)";
		}
	},
	
	/**
	 * Formate un temps en 1/100e de seconde vers une chaîne "mm:ss:cc",
	 * en rajoutant si nécessaire un zéro devant les secondes ou 1/100e de seconde
	 * pour garder un format à deux chiffres.
	 */
	formatRaceTime : function(inTime) {
		inTime = parseInt(inTime);
		var timeString = "--:--:--";
		if (inTime > 0) {
			var cs = inTime%100;
			var seconds = ((inTime-cs)/100)%60;
			var minutes = Math.floor(inTime/6000);
			timeString = (minutes<10?"0":"")+minutes+":"+(seconds<10?"0":"")+seconds+":"+(cs<10?"0":"")+cs;
		}
		return timeString;
	},
	
	/**
	 * Fin / interruption du mode démo
	 * Appelé sur interruption utilisateur (clic souris)
	 */
	exitDemoMode : function(event)
	{
		this.exitDemo = true;
	},
	
	/** 
	 * Activation ou fin de la pause
	 */
	togglePause : function()
	{
		this.pause = !this.pause;
		if (this.pause) {
			this.imageFx.outlinedWrite(this.demoText, _("EN006"));
			this.pauseFrameCounter = 0;
			this.pauseSound();
		} else {
			this.imageFx.outlinedWrite(this.demoText, "");
			this.toggleSound(this.soundOn);
		}
	},
	
	/**
	  * Handler des événements onkeydown (touche clavier pressée)
	  * Bascule les indicateurs accélérateur, frein, volant en valeur true
	  * Les valeurs sont persistées jusqu'au prochain onkeyup
	  * Renvoie true si l'événement doit continuer à être propagé (i.e. non traité ici), false sinon
	  */
	onKeyDown : function(event)
	{	
		return !this.keyControl(event, true);
	},
	
	/**
	  * Handler des événements onkeyup (touche clavier relâchée)
	  * Bascule les indicateurs accélérateur, frein, volant en valeur false
	  * Renvoie true si l'événement doit continuer à être propagé (i.e. non traité ici), false sinon
	  */
	onKeyUp : function(event)
	{	
		return !this.keyControl(event, false);
	},
	
	/**
	 * Handler délégué pour les événements clavier.
	 * Enregistre les appuis et les relâchements des flèches
	 * et de la touche Escape.
	 * Renvoie true si l'événement est géré, false sinon
	 */
	keyControl : function(event, value)
	{
		var handled = true;
		var key = 0;
		if (window.event) { // IE
			key = window.event.keyCode;
		} else { // FF, Opera,...
			key = event.which;
		}
		
		switch (key) {
			case 37 : // fleche gauche
			case 74 : // J
				this.controlLeft = value;
				break;
			case 38 : // fleche haut
			case 65 : // A
				this.controlGas = value;
				break;
			case 39 : // fleche droite
			case 75 : // K
				this.controlRight = value;
				break;
			case 40 : // fleche bas
			case 81 : // Q
				this.controlBrake = value;
				break;
			case 80 : // P
				if (this.currentGear<this.playerCar.topGear && this.raceStarted && value) {
					++this.currentGear;
					this.engineRPM = this.playerSpeed*150.0*this.playerCar.gearRatio[this.currentGear]
				}
				break;
			case 77 : // M
				if (this.currentGear>0 && value) {
					--this.currentGear;
					if (this.currentGear>0) {
						this.engineRPM = this.playerSpeed*150.0*this.playerCar.gearRatio[this.currentGear]
					}
				}
				break;
			case 83 : // S
				if (value) {
					this.toggleSound (!this.soundOn);
				}
				break;
			case 32 : // barre espace
				if (value) {
					this.togglePause();
				}
				break;
			case 27 : // escape
				if (value) {
					this.raceEscaped = true;
				}
				break;
			case 112 : // F1 : vue "arcade", derrière la voiture, mais en suivant l'angle de la route
				this.cameraView = 0;
				break;
			case 113 : // F2 : vue "simu" accrochée derrière la voiture
				this.cameraView = 1;
				break;
			case 114 : // F3 : vue "drift" accrochée au vecteur vitesse
				this.cameraView = 2;
				break;
			case 115 : // F4 : vue "custom" avec delta fixe par rapport à la voiture
				this.cameraView = 3;
				this.customCameraAngle -= 1;
				break;
			case 116 : // F5 : reload
				handled = false;
				break;
			case 117 : // F6 : vue "custom" avec delta fixe par rapport à la voiture
				this.cameraView = 3;
				this.customCameraAngle += 1;
				break;
			default :
				handled = false;
		}
		return handled;
	},
	
	/**
	 * Renvoie l'angle équivalent dans l'intervalle [ref-pi, ref+pi[
	 */
	normalizeAngle : function(angle, ref)
	{
		var loops = Math.round((angle-ref)/6.2832);
		return angle - loops*6.2832;
	}
}

function demoMainLoop(screen)
{
	screen.demoMainLoop();
}

function gameMainLoop(screen)
{
	screen.gameMainLoop();
}

function replayMainLoop(screen)
{
	screen.replayMainLoop();
}
