Article de sortie
Angular v21 est disponible : consultez l'article de blog de la v21 pour découvrir toutes les nouveautés qui vous attendent.
Découvrez la sortie de la v21
Explorez la sortie d'Angular v21 avec ce monde de jeu interactif. Curieux de savoir comment nous l'avons créé ? Cliquez sur le bouton Show Code ci-dessous pour voir le code source de cette application Angular.
Monde de jeu v21
import {Component, computed, effect, inject, signal} from '@angular/core';
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
// 1. INTERFACES
interface Point {
x: number;
y: number;
}
interface Destination {
id: string;
name: string;
position: Point;
videoUrl: string;
display: string;
}
interface RoadSegment {
id: string;
orientation: 'horizontal' | 'vertical';
fixedCoordinate: number;
start: number;
end: number;
}
type WalkingDirection = 'left' | 'right' | 'up' | 'down' | null;
const STAND = 'assets/images/v21-event/mascot.png';
const WALK_LEFT_1 = 'assets/images/v21-event/mascot-left-1.png';
const WALK_LEFT_2 = 'assets/images/v21-event/mascot-left-2.png';
const WALK_RIGHT_1 = 'assets/images/v21-event/mascot-right-1.png';
const WALK_RIGHT_2 = 'assets/images/v21-event/mascot-right-2.png';
const WALK_UP_1 = 'assets/images/v21-event/mascot-up-1.png';
const WALK_UP_2 = 'assets/images/v21-event/mascot-up-2.png';
const WALK_DOWN_1 = 'assets/images/v21-event/mascot-down-1.png';
const WALK_DOWN_2 = 'assets/images/v21-event/mascot-down-2.png';
// Define all unique coordinates as constants for readability and maintanence.
const START_X = 0.45;
const LEFT_X = 0.19;
const RIGHT_X = 0.85;
const PALM_TREE_X = LEFT_X;
const RED_DOOR_X = LEFT_X; // Aligned with Palm Tree for a straight vertical path
const VOLCANO_X = RIGHT_X;
const CASTLE_X = RIGHT_X;
const BOTTOM_Y = 0.6;
const TOP_Y = 0.2;
const RED_DOOR_Y = TOP_Y;
const VOLCANO_Y = TOP_Y;
const PALM_TREE_Y = BOTTOM_Y;
const START_Y = BOTTOM_Y;
const CASTLE_Y = 0.7;
// 2. GAME DATA CONSTANTS
const STARTING_POINT: Point = {x: START_X, y: START_Y};
const DESTINATIONS: Destination[] = [
{
id: 'd1',
name: 'Palm Tree',
position: {x: PALM_TREE_X, y: PALM_TREE_Y},
videoUrl: 'https://www.youtube.com/embed/FteCOhQb4Ow',
display: "What's new in Angular AI",
},
{
id: 'd2',
name: 'Red Door',
position: {x: RED_DOOR_X, y: RED_DOOR_Y},
videoUrl: 'https://www.youtube.com/embed/Cegc5JtWbrI',
display: 'Meet Angular Aria',
},
{
id: 'd3',
name: 'Volcano',
position: {x: VOLCANO_X, y: VOLCANO_Y},
videoUrl: 'https://www.youtube.com/embed/7v8mIW9_NXw',
display: 'Introducing Signal Forms',
},
{
id: 'd4',
name: 'Castle',
position: {x: CASTLE_X, y: CASTLE_Y},
videoUrl: 'https://www.youtube.com/embed/wiWUpCsJ9Os',
display: "Say hello to Angular's new Mascot!",
},
];
const ALL_ROAD_SEGMENTS: RoadSegment[] = [
// Road 1 (Dest 1 <-> Start)
{
id: 'r1',
orientation: 'horizontal',
fixedCoordinate: PALM_TREE_Y,
start: PALM_TREE_X,
end: START_X,
},
// Road 2 (Palm Tree <-> Red Door)
{
id: 'r2',
orientation: 'vertical',
fixedCoordinate: PALM_TREE_X,
start: RED_DOOR_Y,
end: PALM_TREE_Y,
},
// Road 3 (Dest 2 <-> Dest 3)
{
id: 'r3',
orientation: 'horizontal',
fixedCoordinate: RED_DOOR_Y,
start: RED_DOOR_X,
end: VOLCANO_X,
},
// Road 4 (Dest 3 <-> Dest 4)
{id: 'r4', orientation: 'vertical', fixedCoordinate: VOLCANO_X, start: VOLCANO_Y, end: CASTLE_Y},
];
// 3. GAME MECHANICS CONSTANTS
const MOVE_STEP = 0.0025;
const ANIMATION_SPEED = 28; // Higher is slower. Update image every 10 frames.
// How far off a road's axis the character can be
const MOVE_TOLERANCE = 0.002;
// How close to a destination to "arrive" (must be very small)
const DESTINATION_TOLERANCE = 0.005;
@Component({
selector: 'app-root',
template: `
<div
class="game-container"
[style.background-image]="'url(assets/images/v21-event/world-map.png)'"
>
<!-- Keys -->
<div class="keys-container">
@for (key of keysToShow(); track key) {
<img
src="assets/images/v21-event/key.png"
class="key-icon"
animate.enter="key-enter-animation"
/>
}
@if (showMascot()) {
<img
src="assets/images/v21-event/mascot.png"
class="mascot-icon"
animate.enter="key-enter-animation"
/>
}
</div>
<!-- Character -->
@if (!isDialogOpen()) {
<div
class="character"
[style.left.%]="characterXPercent()"
[style.top.%]="characterYPercent()"
[style.background-image]="'url(' + characterImageUrl() + ')'"
></div>
}
<!-- Destinations -->
@for (dest of destinations(); track dest.id) {
<div
class="destination-hotspot"
[style.left.%]="dest.position.x * 100"
[style.top.%]="dest.position.y * 100"
[class.glowing]="activeDestination()?.id === dest.id"
></div>
}
<!-- D-Pad Controls -->
<div class="d-pad">
<button
class="d-pad-button up"
(mousedown)="handleButtonPress('ArrowUp')"
(mouseup)="handleButtonRelease('ArrowUp')"
(mouseleave)="handleButtonRelease('ArrowUp')"
(touchstart)="handleButtonPress('ArrowUp')"
(touchend)="handleButtonRelease('ArrowUp')"
>
▲
</button>
<button
class="d-pad-button left"
(mousedown)="handleButtonPress('ArrowLeft')"
(mouseup)="handleButtonRelease('ArrowLeft')"
(mouseleave)="handleButtonRelease('ArrowLeft')"
(touchstart)="handleButtonPress('ArrowLeft')"
(touchend)="handleButtonRelease('ArrowLeft')"
>
◀
</button>
<button
class="d-pad-button right"
(mousedown)="handleButtonPress('ArrowRight')"
(mouseup)="handleButtonRelease('ArrowRight')"
(mouseleave)="handleButtonRelease('ArrowRight')"
(touchstart)="handleButtonPress('ArrowRight')"
(touchend)="handleButtonRelease('ArrowRight')"
>
▶
</button>
<button
class="d-pad-button down"
(mousedown)="handleButtonPress('ArrowDown')"
(mouseup)="handleButtonRelease('ArrowDown')"
(mouseleave)="handleButtonRelease('ArrowDown')"
(touchstart)="handleButtonPress('ArrowDown')"
(touchend)="handleButtonRelease('ArrowDown')"
>
▼
</button>
</div>
<!-- Info Sign -->
@if (!isDialogOpen()) {
<img [src]="infoSignImageUrl()" alt="Info Sign" class="info-sign" />
}
<!-- Dialog Box -->
@if (isDialogOpen()) {
<div class="dialog-overlay" (click)="closeDialog()">
<div class="dialog-content" (click)="$event.stopPropagation()">
<button class="close-icon" (click)="closeDialog()">×</button>
<h2>{{ activeDestination()?.display }}</h2>
@if (safeVideoUrl(); as url) {
<iframe
credentialless
[src]="url"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowfullscreen
></iframe>
}
<button class="close-button" (click)="closeDialog()">Close</button>
</div>
</div>
}
<!-- Explore Button -->
@if (activeDestination() && (activeDestination()?.id !== 'd4' || allKeysCollected())) {
<button class="explore-button" (click)="isDialogOpen.set(true)">Enter</button>
}
</div>
`,
styles: [
`
.keys-container {
position: absolute;
top: 1cqw;
left: 1cqw;
display: flex;
z-index: 20;
}
.key-icon {
width: 4cqw;
height: 5cqw;
}
.mascot-icon {
width: 4cqw;
height: 5cqw;
margin-left: 1cqw;
}
.key-enter-animation {
animation: growIn 0.5s ease-in-out;
}
@keyframes growIn {
from {
transform: scale(0.1);
}
to {
transform: scale(1);
}
}
:host {
display: block;
width: 100%;
height: 100%;
}
.game-container {
width: 100%;
aspect-ratio: 16 / 9;
position: relative;
overflow: hidden;
background-size: 100% 100%;
background-repeat: no-repeat;
background-color: #3a3a3a; /* Fallback color */
container-type: inline-size;
container-name: game-container;
}
.character {
z-index: 10;
position: absolute;
width: 9cqw;
height: 9cqw;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
transform: translate(-50%, -60%);
}
.destination-hotspot {
position: absolute;
width: 4cqw;
height: 4cqw;
border-radius: 50%;
transform: translate(-50%, -10%);
transition: all 0.3s ease;
}
.destination-hotspot.glowing {
background-color: rgba(255, 0, 242, 0.5);
box-shadow: 0 0 5px 15px rgba(255, 0, 242, 0.7);
}
.d-pad {
position: absolute;
bottom: 2cqw;
left: 2cqw;
width: 12cqw;
height: 12cqw;
display: grid;
grid-template-areas:
'. up .'
'left . right'
'. down .';
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5cqw;
z-index: 20;
}
.d-pad-button {
background-color: rgba(0, 0, 0, 0.5);
border: none;
border-radius: 0.5cqw;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s;
touch-action: manipulation; /* Prevent double tap zoom */
color: white;
font-size: 2.5cqw;
font-weight: bold;
}
.d-pad-button:hover,
.d-pad-button:active {
background-color: rgba(0, 0, 0, 0.8);
}
.d-pad-button.up {
grid-area: up;
}
.d-pad-button.down {
grid-area: down;
}
.d-pad-button.left {
grid-area: left;
}
.d-pad-button.right {
grid-area: right;
}
.info-sign {
position: absolute;
top: 69%;
left: 50%;
transform: translate(-50%, 0%);
width: 50cqw;
height: 18cqw;
object-fit: contain; /* Ensures the image fits within the bounds */
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
}
.dialog-content {
position: absolute;
top: 70%;
left: 50%;
transform: translate(-50%, -80%);
background: white;
border-radius: 5px;
color: black;
text-align: center;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
width: 60%;
height: 76%;
padding: 1rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog-content h2 {
margin-top: 0;
margin-bottom: 1rem;
font-family: 'Jersey 10', sans-serif;
}
.dialog-content iframe {
width: 100%;
flex-grow: 1;
border: none;
border-radius: 8px;
}
.close-button {
margin-top: 1rem;
padding: 10px 20px;
border: none;
background-color: #5c44e4;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s ease;
}
.close-button:hover {
background-color: #8514f5;
}
.close-icon {
position: absolute;
top: 10px;
right: 10px;
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
padding: 5px;
line-height: 1;
}
.close-icon:hover {
color: #000;
}
.explore-button {
position: absolute;
bottom: 2cqw;
right: 2cqw;
padding: 1.5cqw 3cqw;
background-color: #e90464;
color: white;
border: 2px solid white; /* White border */
border-radius: 1cqw;
font-size: 2cqw;
font-weight: bold;
cursor: pointer;
transition:
background-color 0.2s,
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
z-index: 20;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.explore-button:hover {
background-color: #c20354;
}
/* When activeDestination() is true, the button is in the DOM */
@if (activeDestination()) {
.explore-button {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
`,
],
host: {
'(window:keydown)': 'handleKeydown($event)',
'(window:keyup)': 'handleKeyup($event)',
},
})
export class App {
characterPosition = signal<Point>(STARTING_POINT);
isDialogOpen = signal<boolean>(false);
visitedKeyDestinations = signal<Set<string>>(new Set());
keysToShow = computed(() => Array.from(this.visitedKeyDestinations()));
allKeysCollected = computed(() => this.keysToShow().length === 3);
showMascot = signal(false);
destinations = signal<Destination[]>(DESTINATIONS);
walkingDirection = signal<WalkingDirection>(null);
walkFrame = signal(0);
characterImageUrl = computed(() => {
if (this.activeDestination()) {
return STAND;
}
const direction = this.walkingDirection();
const frame = this.walkFrame();
const animationFrame = Math.floor(frame / ANIMATION_SPEED) % 2;
switch (direction) {
case 'left':
return animationFrame === 0 ? WALK_LEFT_1 : WALK_LEFT_2;
case 'right':
return animationFrame === 0 ? WALK_RIGHT_1 : WALK_RIGHT_2;
case 'up':
return animationFrame === 0 ? WALK_UP_1 : WALK_UP_2;
case 'down':
return animationFrame === 0 ? WALK_DOWN_1 : WALK_DOWN_2;
default:
return STAND;
}
});
safeVideoUrl = signal<SafeResourceUrl | null>(null);
pressedKeys = signal<Set<string>>(new Set());
infoSignImageUrl = computed(() => {
const activeDest = this.activeDestination();
if (this.allKeysCollected() && this.showMascot()) {
return 'assets/images/v21-event/congrats-sign.png';
} else if (!activeDest) {
return 'assets/images/v21-event/welcome-sign.png';
} else if (activeDest.name === 'Castle') {
return this.allKeysCollected()
? 'assets/images/v21-event/castle-sign.png'
: 'assets/images/v21-event/entry-denied-sign.png';
} else {
return 'assets/images/v21-event/enter-sign.png';
}
});
private sanitizer = inject(DomSanitizer);
constructor() {
effect(() => {
const destination = this.activeDestination();
if (destination?.videoUrl) {
this.safeVideoUrl.set(this.sanitizer.bypassSecurityTrustResourceUrl(destination.videoUrl));
} else {
this.safeVideoUrl.set(null);
}
});
this.gameLoop();
}
gameLoop() {
const keys = this.pressedKeys();
if (!this.isDialogOpen()) {
const currentPos = this.characterPosition();
let newPos = {...currentPos};
let moveDirection: 'horizontal' | 'vertical' | null = null;
if (keys.has('ArrowUp')) {
newPos.y -= MOVE_STEP;
moveDirection = 'vertical';
this.walkingDirection.set('up');
} else if (keys.has('ArrowDown')) {
newPos.y += MOVE_STEP;
moveDirection = 'vertical';
this.walkingDirection.set('down');
} else if (keys.has('ArrowLeft')) {
newPos.x -= MOVE_STEP;
moveDirection = 'horizontal';
this.walkingDirection.set('left');
} else if (keys.has('ArrowRight')) {
newPos.x += MOVE_STEP;
moveDirection = 'horizontal';
this.walkingDirection.set('right');
} else {
this.walkingDirection.set(null);
}
if (moveDirection && this.isMoveAllowed(currentPos, newPos, moveDirection)) {
this.characterPosition.set(newPos);
this.walkFrame.update((frame) => frame + 1);
}
}
requestAnimationFrame(() => this.gameLoop());
}
// 5. COMPUTED SIGNALS (DERIVED STATE)
characterXPercent = computed(() => this.characterPosition().x * 100);
characterYPercent = computed(() => this.characterPosition().y * 100);
activeDestination = computed<Destination | null>(() => {
const pos = this.characterPosition();
for (const dest of this.destinations()) {
const distance = Math.sqrt(
Math.pow(pos.x - dest.position.x, 2) + Math.pow(pos.y - dest.position.y, 2),
);
if (distance < DESTINATION_TOLERANCE) {
return dest;
}
}
return null;
});
// 6. EVENT HANDLERS & METHODS
handleKeydown(event: KeyboardEvent) {
if (this.isDialogOpen() && event.key !== 'Escape') {
return;
}
this.preventArrowDefault(event);
this.pressedKeys.update((keys) => keys.add(event.key));
if (
event.key === 'Enter' &&
this.activeDestination() &&
(this.activeDestination()?.id !== 'd4' || this.allKeysCollected())
) {
this.isDialogOpen.set(true);
}
if (event.key === 'Escape' && this.isDialogOpen()) {
this.closeDialog();
}
}
handleKeyup(event: KeyboardEvent) {
this.preventArrowDefault(event);
this.pressedKeys.update((keys) => {
keys.delete(event.key);
return keys;
});
}
preventArrowDefault(event: KeyboardEvent) {
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
if (arrowKeys.includes(event.key)) {
event.preventDefault();
}
}
handleButtonPress(key: string) {
this.pressedKeys.update((keys) => keys.add(key));
}
handleButtonRelease(key: string) {
this.pressedKeys.update((keys) => {
keys.delete(key);
return keys;
});
}
closeDialog(): void {
const activeDest = this.activeDestination();
if (activeDest && ['d1', 'd2', 'd3'].includes(activeDest.id)) {
this.visitedKeyDestinations.update((visited) => {
if (!visited.has(activeDest.id)) {
visited.add(activeDest.id);
return new Set(visited); // Return new Set to trigger update
}
return visited;
});
}
if (activeDest?.id === 'd4' && this.allKeysCollected()) {
this.showMascot.set(true);
}
this.isDialogOpen.set(false);
}
private isMoveAllowed(
currentPos: Point,
newPos: Point,
direction: 'horizontal' | 'vertical',
): boolean {
// Find the road the character is currently on by checking the axis perpendicular to movement.
let currentRoad: RoadSegment | null = null;
let minDistance = Infinity;
for (const road of ALL_ROAD_SEGMENTS) {
if (direction === 'horizontal' && road.orientation === 'horizontal') {
const distance = Math.abs(currentPos.y - road.fixedCoordinate);
if (distance < minDistance) {
minDistance = distance;
currentRoad = road;
}
} else if (direction === 'vertical' && road.orientation === 'vertical') {
const distance = Math.abs(currentPos.x - road.fixedCoordinate);
if (distance < minDistance) {
minDistance = distance;
currentRoad = road;
}
}
}
// If no road is close enough, movement is not allowed.
if (!currentRoad || minDistance > MOVE_TOLERANCE) {
return false;
}
// Check if the new position is within the bounds of the identified road.
if (currentRoad.orientation === 'horizontal') {
return (
newPos.x >= Math.min(currentRoad.start, currentRoad.end) - MOVE_TOLERANCE &&
newPos.x <= Math.max(currentRoad.start, currentRoad.end) + MOVE_TOLERANCE
);
} else {
// Vertical
return (
newPos.y >= Math.min(currentRoad.start, currentRoad.end) - MOVE_TOLERANCE &&
newPos.y <= Math.max(currentRoad.start, currentRoad.end) + MOVE_TOLERANCE
);
}
}
}
Événement développeurs Angular v21 [version complète]
Angular v21 vous est livrée sous la forme d'une toute nouvelle aventure. Avec des outils d'IA modernes, des améliorations de performances et bien plus, Angular v21 apporte de nouvelles fonctionnalités remarquables pour améliorer votre expérience de développement. Que vous créiez des applications basées sur l'IA ou des applications d'entreprise évolutives, il n'y a jamais eu de meilleur moment pour construire avec Angular.
🔥 Les nouveautés de la v21
- De nouveaux outils du serveur MCP Angular pour améliorer les workflows et la génération de code basés sur l'IA
- Un premier aperçu des Signal Forms, notre nouvelle approche simplifiée des formulaires dans Angular, basée sur les signals
- De nouveaux détails passionnants sur le package Angular Aria