diff --git a/adev/BUILD.bazel b/adev/BUILD.bazel index 497301c1ce8..0316eac26e1 100644 --- a/adev/BUILD.bazel +++ b/adev/BUILD.bazel @@ -15,6 +15,7 @@ exports_files([ APPLICATION_FILES = [ "//adev/src/assets/images", + "//adev/src/assets/images/v21-event:v21-event-images", "//adev/src/assets/others", "//adev/src/assets/previews", "//adev/src/assets:tutorials", diff --git a/adev/angular.json b/adev/angular.json index 7368c7a1fad..de7df65a4b1 100644 --- a/adev/angular.json +++ b/adev/angular.json @@ -32,7 +32,8 @@ "src/favicon.ico", "src/robots.txt", "src/llms.txt", - "src/assets" + "src/assets", + "src/assets/images/v21-event" ], "styles": ["@angular/docs/styles/global-styles.scss", "./src/local-styles.scss"], "scripts": [], diff --git a/adev/src/assets/images/angular-v21-hero.jpg b/adev/src/assets/images/angular-v21-hero.jpg deleted file mode 100644 index fb0dc7e7bde..00000000000 Binary files a/adev/src/assets/images/angular-v21-hero.jpg and /dev/null differ diff --git a/adev/src/assets/images/v21-event/BUILD.bazel b/adev/src/assets/images/v21-event/BUILD.bazel new file mode 100644 index 00000000000..9b89c177a55 --- /dev/null +++ b/adev/src/assets/images/v21-event/BUILD.bazel @@ -0,0 +1,11 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") + +package(default_visibility = ["//visibility:public"]) + +js_library( + name = "v21-event-images", + srcs = glob([ + "*.png", + "*.jpg", + ]), +) diff --git a/adev/src/assets/images/v21-event/angular-v21-hero.jpg b/adev/src/assets/images/v21-event/angular-v21-hero.jpg new file mode 100644 index 00000000000..247b88d643b Binary files /dev/null and b/adev/src/assets/images/v21-event/angular-v21-hero.jpg differ diff --git a/adev/src/assets/images/v21-event/castle-sign.png b/adev/src/assets/images/v21-event/castle-sign.png new file mode 100644 index 00000000000..b1e516714cf Binary files /dev/null and b/adev/src/assets/images/v21-event/castle-sign.png differ diff --git a/adev/src/assets/images/v21-event/congrats-sign.png b/adev/src/assets/images/v21-event/congrats-sign.png new file mode 100644 index 00000000000..4768dffe125 Binary files /dev/null and b/adev/src/assets/images/v21-event/congrats-sign.png differ diff --git a/adev/src/assets/images/v21-event/enter-sign.png b/adev/src/assets/images/v21-event/enter-sign.png new file mode 100644 index 00000000000..6682fec7e7b Binary files /dev/null and b/adev/src/assets/images/v21-event/enter-sign.png differ diff --git a/adev/src/assets/images/v21-event/entry-denied-sign.png b/adev/src/assets/images/v21-event/entry-denied-sign.png new file mode 100644 index 00000000000..d0a8c07d3f6 Binary files /dev/null and b/adev/src/assets/images/v21-event/entry-denied-sign.png differ diff --git a/adev/src/assets/images/v21-event/key.png b/adev/src/assets/images/v21-event/key.png new file mode 100644 index 00000000000..dd83af609bf Binary files /dev/null and b/adev/src/assets/images/v21-event/key.png differ diff --git a/adev/src/assets/images/v21-event/mascot-down-1.png b/adev/src/assets/images/v21-event/mascot-down-1.png new file mode 100644 index 00000000000..76899638fcf Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-down-1.png differ diff --git a/adev/src/assets/images/v21-event/mascot-down-2.png b/adev/src/assets/images/v21-event/mascot-down-2.png new file mode 100644 index 00000000000..82f3ae41c6a Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-down-2.png differ diff --git a/adev/src/assets/images/v21-event/mascot-left-1.png b/adev/src/assets/images/v21-event/mascot-left-1.png new file mode 100644 index 00000000000..060551340b1 Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-left-1.png differ diff --git a/adev/src/assets/images/v21-event/mascot-left-2.png b/adev/src/assets/images/v21-event/mascot-left-2.png new file mode 100644 index 00000000000..b9ad270f8ce Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-left-2.png differ diff --git a/adev/src/assets/images/v21-event/mascot-right-1.png b/adev/src/assets/images/v21-event/mascot-right-1.png new file mode 100644 index 00000000000..8a68248f633 Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-right-1.png differ diff --git a/adev/src/assets/images/v21-event/mascot-right-2.png b/adev/src/assets/images/v21-event/mascot-right-2.png new file mode 100644 index 00000000000..fd574944b83 Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-right-2.png differ diff --git a/adev/src/assets/images/v21-event/mascot-up-1.png b/adev/src/assets/images/v21-event/mascot-up-1.png new file mode 100644 index 00000000000..9f01e0af93b Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-up-1.png differ diff --git a/adev/src/assets/images/v21-event/mascot-up-2.png b/adev/src/assets/images/v21-event/mascot-up-2.png new file mode 100644 index 00000000000..dde41ede582 Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot-up-2.png differ diff --git a/adev/src/assets/images/v21-event/mascot.png b/adev/src/assets/images/v21-event/mascot.png new file mode 100644 index 00000000000..05bff9ffebc Binary files /dev/null and b/adev/src/assets/images/v21-event/mascot.png differ diff --git a/adev/src/assets/images/v21-event/welcome-sign.png b/adev/src/assets/images/v21-event/welcome-sign.png new file mode 100644 index 00000000000..b582f466a54 Binary files /dev/null and b/adev/src/assets/images/v21-event/welcome-sign.png differ diff --git a/adev/src/assets/images/v21-event/world-map.png b/adev/src/assets/images/v21-event/world-map.png new file mode 100644 index 00000000000..5c18e9ea96a Binary files /dev/null and b/adev/src/assets/images/v21-event/world-map.png differ diff --git a/adev/src/content/events/BUILD.bazel b/adev/src/content/events/BUILD.bazel index 20895866720..ce4f96ff543 100644 --- a/adev/src/content/events/BUILD.bazel +++ b/adev/src/content/events/BUILD.bazel @@ -7,8 +7,9 @@ generate_guides( ]), api_manifest = "//adev/src/assets:docs_api_manifest", data = [ - "//adev/src/assets/images:angular-v21-hero.jpg", + "//adev/src/assets/images/v21-event:v21-event-images", "//adev/src/assets/others:angular-v21-release.ics", + "//adev/src/content/examples", ], visibility = ["//adev:__subpackages__"], ) diff --git a/adev/src/content/events/v21.md b/adev/src/content/events/v21.md index ae3f6bb5b60..d26d5ce36ca 100644 --- a/adev/src/content/events/v21.md +++ b/adev/src/content/events/v21.md @@ -1,29 +1,37 @@ -![A retro 8-bit, pixel art style graphic announcing the upcoming release of Angular v21. The large, gradient 'v21' text dominates the frame. Next to it, in smaller text, are the words 'The Adventure Begins' and the release date '11-20-2025' inside a pink pixelated box. The Angular logo is in the bottom right corner.](assets/images/angular-v21-hero.jpg 'Angular v21 Hero Image') +![A retro 8-bit, pixel art style graphic announcing the upcoming release of Angular v21. The large, gradient 'v21' text dominates the frame. Next to it, in smaller text, are the words 'The Adventure Begins' and the release date '11-20-2025' inside a pink pixelated box. The Angular logo is in the bottom right corner.](assets/images/v21-event/angular-v21-hero.jpg 'Angular v21 Hero Image') # Angular v21: The Adventure Begins +## Release Blog + **Angular v21 is live**: check out the [v21 release blog](http://goo.gle/angular-v21-blog) to learn about all of the amazing new features coming your way. -## Save the Date November 20, 2025 +## Experience the v21 Release - - - - +Explore the Angular v21 release with this interactive game world. Curious how we made it? Click the `Show Code` button below to view the source code for this Angular app. -
+ -Join the Angular team this November for a brand new release adventure. With modern AI tooling, performance updates and more, Angular v21 delivers fantastic new features to improve your developer experience. Whether you’re creating AI-powered apps or scalable enterprise applications, there has never been a better time to build with Angular. +## Angular v21 Developer Event [Full Version] + +Angular v21 is being delivered to you as a brand new release adventure. With modern AI tooling, performance updates and more, Angular v21 delivers fantastic new features to improve your developer experience. Whether you’re creating AI-powered apps or scalable enterprise applications, there has never been a better time to build with Angular. 🔥 What's coming in v21 - New Angular MCP Server tools to improve AI-powered workflows and code generation - Your first look at Signal Forms, our new streamlined, signal-based approach to forms in Angular -- Exciting new details about the Angular ARIA package +- Exciting new details about the Angular Aria package -That’s just a taste of what’s coming in our upcoming major release event. You won't want to miss it. - - - - - +
+ +
diff --git a/adev/src/content/examples/v21-game-world/e2e/src/app.e2e-spec.ts b/adev/src/content/examples/v21-game-world/e2e/src/app.e2e-spec.ts new file mode 100644 index 00000000000..2b018bbf1f4 --- /dev/null +++ b/adev/src/content/examples/v21-game-world/e2e/src/app.e2e-spec.ts @@ -0,0 +1,172 @@ +import {browser, element, by, Key, ExpectedConditions} from 'protractor'; + +describe('Angular v21 World', () => { + beforeEach(() => browser.get('')); + + async function getCharacterPosition(): Promise<{x: string; y: string}> { + const character = element(by.css('.character')); + const left = await character.getCssValue('left'); + const top = await character.getCssValue('top'); + return {x: left, y: top}; + } + + it('should display the initial game state correctly', async () => { + // Character is visible + expect(await element(by.css('.character')).isDisplayed()).toBe(true); + + // Info sign is shown with welcome message + const infoSign = element(by.css('.info-sign')); + expect(await infoSign.isDisplayed()).toBe(true); + expect(await infoSign.getAttribute('src')).toContain('welcome-sign.png'); + + // D-pad is visible + expect(await element(by.css('.d-pad')).isDisplayed()).toBe(true); + + // Explore button is not visible + expect(await element(by.css('.explore-button')).isPresent()).toBe(false); + + // No keys are present + expect(await element.all(by.css('.key-icon')).count()).toBe(0); + }); + + it('should move the character with the D-pad buttons', async () => { + const initialPosition = await getCharacterPosition(); + const leftButton = element(by.css('.d-pad-button.left')); + + // Hold the button down for a short period to simulate walking + await browser.actions().mouseDown(leftButton).perform(); + await browser.sleep(200); + await browser.actions().mouseUp(leftButton).perform(); + + const newPosition = await getCharacterPosition(); + expect(newPosition.x).not.toEqual(initialPosition.x); + }); + + it('should move the character with keyboard arrow keys', async () => { + const initialPosition = await getCharacterPosition(); + + // Send arrow key press + await browser.actions().sendKeys(Key.ARROW_RIGHT).perform(); + await browser.sleep(200); // Allow time for movement + await browser.actions().sendKeys(Key.NULL).perform(); // Release key + + const newPosition = await getCharacterPosition(); + expect(newPosition.x).not.toEqual(initialPosition.x); + }); + + it('should show explore button, open dialog, and collect a key when a destination is reached', async () => { + const body = element(by.css('body')); + const exploreButton = element(by.css('.explore-button')); + const dialog = element(by.css('.dialog-overlay')); + + // Move left until we reach the Palm Tree destination + for (let i = 0; i < 20; i++) { + await body.sendKeys(Key.ARROW_LEFT); + await browser.sleep(100); + } + + // Wait for the explore button to appear and check info sign + await browser.wait(ExpectedConditions.visibilityOf(exploreButton), 5000); + expect(await exploreButton.isDisplayed()).toBe(true); + expect(await element(by.css('.info-sign')).getAttribute('src')).toContain('enter-sign.png'); + + // Click explore button to open dialog + await exploreButton.click(); + await browser.wait(ExpectedConditions.visibilityOf(dialog), 1000); + expect(await dialog.isDisplayed()).toBe(true); + expect(await dialog.element(by.css('h2')).getText()).toEqual("What's new in Angular AI"); + + // Close the dialog + await dialog.element(by.css('.close-button')).click(); + await browser.wait(ExpectedConditions.invisibilityOf(dialog), 1000); + expect(await dialog.isPresent()).toBe(false); + + // Check that one key has been collected + expect(await element.all(by.css('.key-icon')).count()).toBe(1); + }); + + it('should show entry denied at castle without all keys', async () => { + const body = element(by.css('body')); + const exploreButton = element(by.css('.explore-button')); + + // Move to a position near the castle without collecting keys + for (let i = 0; i < 20; i++) (await body.sendKeys(Key.ARROW_RIGHT), await browser.sleep(100)); + for (let i = 0; i < 20; i++) (await body.sendKeys(Key.ARROW_DOWN), await browser.sleep(100)); + + await browser.sleep(1000); // Settle + + // Check that the entry denied sign is shown and the button is not present + expect(await element(by.css('.info-sign')).getAttribute('src')).toContain( + 'entry-denied-sign.png', + ); + expect(await exploreButton.isPresent()).toBe(false); + }); + + it('should handle the full game flow and show congrats state', async () => { + const body = element(by.css('body')); + const exploreButton = element(by.css('.explore-button')); + const dialog = element(by.css('.dialog-overlay')); + const keys = element.all(by.css('.key-icon')); + const mascot = element(by.css('.mascot-icon')); + + // **Navigate to Palm Tree (d1) and collect the first key** + for (let i = 0; i < 20; i++) { + await body.sendKeys(Key.ARROW_LEFT); + await browser.sleep(100); + } + await browser.wait(ExpectedConditions.visibilityOf(exploreButton), 5000); + await exploreButton.click(); + await browser.wait(ExpectedConditions.visibilityOf(dialog), 1000); + await dialog.element(by.css('.close-button')).click(); + await browser.wait(ExpectedConditions.invisibilityOf(dialog), 1000); + expect(await keys.count()).toBe(1); + + // **Navigate to Red Door (d2) and collect the second key** + for (let i = 0; i < 15; i++) { + await body.sendKeys(Key.ARROW_UP); + await browser.sleep(100); + } + await browser.wait(ExpectedConditions.visibilityOf(exploreButton), 5000); + await exploreButton.click(); + await browser.wait(ExpectedConditions.visibilityOf(dialog), 1000); + await dialog.element(by.css('.close-button')).click(); + await browser.wait(ExpectedConditions.invisibilityOf(dialog), 1000); + expect(await keys.count()).toBe(2); + + // **Navigate to Volcano (d3) and collect the third key** + for (let i = 0; i < 25; i++) { + await body.sendKeys(Key.ARROW_RIGHT); + await browser.sleep(100); + } + await browser.wait(ExpectedConditions.visibilityOf(exploreButton), 5000); + await exploreButton.click(); + await browser.wait(ExpectedConditions.visibilityOf(dialog), 1000); + await dialog.element(by.css('.close-button')).click(); + await browser.wait(ExpectedConditions.invisibilityOf(dialog), 1000); + expect(await keys.count()).toBe(3); + + // **Navigate to Castle (d4) with all keys** + for (let i = 0; i < 15; i++) { + await body.sendKeys(Key.ARROW_DOWN); + await browser.sleep(100); + } + await browser.sleep(1000); // Wait for character to settle + + // Check for correct sign and button visibility + expect(await element(by.css('.info-sign')).getAttribute('src')).toContain('castle-sign.png'); + await browser.wait(ExpectedConditions.visibilityOf(exploreButton), 5000); + + // **Open Castle dialog, close it, and see mascot and final sign** + await exploreButton.click(); + await browser.wait(ExpectedConditions.visibilityOf(dialog), 1000); + await dialog.element(by.css('.close-button')).click(); + await browser.wait(ExpectedConditions.invisibilityOf(dialog), 1000); + + // Mascot appears + await browser.wait(ExpectedConditions.visibilityOf(mascot), 1000); + expect(await mascot.isDisplayed()).toBe(true); + + // Congrats sign appears + expect(await element(by.css('.info-sign')).getAttribute('src')).toContain('congrats-sign.png'); + }); +}); diff --git a/adev/src/content/examples/v21-game-world/example-config.json b/adev/src/content/examples/v21-game-world/example-config.json new file mode 100644 index 00000000000..ccf472031b4 --- /dev/null +++ b/adev/src/content/examples/v21-game-world/example-config.json @@ -0,0 +1,6 @@ +{ + "tests": [ + {"cmd": "yarn", "args": ["test", "--browsers=ChromeHeadlessNoSandbox", "--no-watch"]}, + {"cmd": "yarn", "args": ["e2e", "--configuration=production", "--protractor-config=e2e/protractor-bazel.conf.js", "--no-webdriver-update", "--port=0"]} + ] +} diff --git a/adev/src/content/examples/v21-game-world/src/app/app.ts b/adev/src/content/examples/v21-game-world/src/app/app.ts new file mode 100644 index 00000000000..039a95d86db --- /dev/null +++ b/adev/src/content/examples/v21-game-world/src/app/app.ts @@ -0,0 +1,723 @@ +import {ChangeDetectionStrategy, 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: ` +
+ +
+ @for (key of keysToShow(); track key) { + + } + @if (showMascot()) { + + } +
+ + + @if (!isDialogOpen()) { +
+ } + + + @for (dest of destinations(); track dest.id) { +
+ } + + +
+ + + + +
+ + + @if (!isDialogOpen()) { + Info Sign + } + + + @if (isDialogOpen()) { +
+
+ +

{{ activeDestination()?.display }}

+ @if (safeVideoUrl(); as url) { + + } + +
+
+ } + + @if (activeDestination() && (activeDestination()?.id !== 'd4' || allKeysCollected())) { + + } +
+ `, + 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; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '(window:keydown)': 'handleKeydown($event)', + '(window:keyup)': 'handleKeyup($event)', + }, +}) +export class App { + characterPosition = signal(STARTING_POINT); + isDialogOpen = signal(false); + visitedKeyDestinations = signal>(new Set()); + keysToShow = computed(() => Array.from(this.visitedKeyDestinations())); + allKeysCollected = computed(() => this.keysToShow().length === 3); + showMascot = signal(false); + destinations = signal(DESTINATIONS); + walkingDirection = signal(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(null); + pressedKeys = signal>(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(() => { + 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 + ); + } + } +} diff --git a/adev/src/content/examples/v21-game-world/src/index.html b/adev/src/content/examples/v21-game-world/src/index.html new file mode 100644 index 00000000000..2b169cb432b --- /dev/null +++ b/adev/src/content/examples/v21-game-world/src/index.html @@ -0,0 +1,13 @@ + + + + + v21 Game World + + + + + + + + diff --git a/adev/src/content/examples/v21-game-world/src/main.ts b/adev/src/content/examples/v21-game-world/src/main.ts new file mode 100644 index 00000000000..438d1c34c7e --- /dev/null +++ b/adev/src/content/examples/v21-game-world/src/main.ts @@ -0,0 +1,9 @@ +import {bootstrapApplication, provideProtractorTestingSupport} from '@angular/platform-browser'; + +import {App} from './app/app'; + +bootstrapApplication(App, { + providers: [ + provideProtractorTestingSupport(), // essential for e2e testing + ], +}); diff --git a/adev/src/content/examples/v21-game-world/stackblitz.json b/adev/src/content/examples/v21-game-world/stackblitz.json new file mode 100644 index 00000000000..a27408cfec1 --- /dev/null +++ b/adev/src/content/examples/v21-game-world/stackblitz.json @@ -0,0 +1,7 @@ +{ + "description": "Pipes", + "files":[ + "!**/*.d.ts", + "!**/*.js"], + "tags": ["pipe"] +}