docs: add v21 event game

This commit is contained in:
Devin Chasanoff 2025-11-17 18:19:50 -05:00 committed by Kirill Cherkashin
parent be6f2f066f
commit b8fa9cc7d5
29 changed files with 969 additions and 17 deletions

View file

@ -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",

View file

@ -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": [],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -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",
]),
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View file

@ -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__"],
)

View file

@ -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
<docs-pill-row>
<docs-pill href="https://calendar.google.com/calendar/render?action=TEMPLATE&text=Angular+v21+Launch&dates=20251120T170000Z/20251120T173000Z&details=Get+ready+for+the+future+of+web+development+with+Angular.+Tune+in+https://goo.gle/angular-v21&location=https://goo.gle/angular-v21" title="Add to Google Calendar"/>
<docs-pill href="/assets/others/angular-v21-release.ics" title="Add to iCal/Outlook" download="angular-v21-release.ics" target="_blank" />
</docs-pill-row>
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.
<hr/>
<docs-code preview hideCode header="v21 Game World" path="adev/src/content/examples/v21-game-world/src/app/app.ts" />
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 youre 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 youre 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
Thats just a taste of whats coming in our upcoming major release event. You won't want to miss it.
<docs-pill-row>
<docs-pill href="https://calendar.google.com/calendar/render?action=TEMPLATE&text=Angular+v21+Launch&dates=20251120T170000Z/20251120T173000Z&details=Get+ready+for+the+future+of+web+development+with+Angular.+Tune+in+https://goo.gle/angular-v21&location=https://goo.gle/angular-v21" title="Add to Google Calendar"/>
<docs-pill href="/assets/others/angular-v21-release.ics" title="Add to iCal/Outlook" download="angular-v21-release.ics" target="_blank" />
</docs-pill-row>
<div style="display: block; width: 80%; margin: 0 auto; margin-top: 20px;">
<iframe
credentialless
width="560"
height="315"
src="https://www.youtube.com/embed/DDAHORVzQ5g?si=B9Uv5vXzIKJfcLGr"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>

View file

@ -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');
});
});

View file

@ -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"]}
]
}

View file

@ -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: `
<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')"
>
&#x25B2;
</button>
<button
class="d-pad-button left"
(mousedown)="handleButtonPress('ArrowLeft')"
(mouseup)="handleButtonRelease('ArrowLeft')"
(mouseleave)="handleButtonRelease('ArrowLeft')"
(touchstart)="handleButtonPress('ArrowLeft')"
(touchend)="handleButtonRelease('ArrowLeft')"
>
&#x25C0;
</button>
<button
class="d-pad-button right"
(mousedown)="handleButtonPress('ArrowRight')"
(mouseup)="handleButtonRelease('ArrowRight')"
(mouseleave)="handleButtonRelease('ArrowRight')"
(touchstart)="handleButtonPress('ArrowRight')"
(touchend)="handleButtonRelease('ArrowRight')"
>
&#x25B6;
</button>
<button
class="d-pad-button down"
(mousedown)="handleButtonPress('ArrowDown')"
(mouseup)="handleButtonRelease('ArrowDown')"
(mouseleave)="handleButtonRelease('ArrowDown')"
(touchstart)="handleButtonPress('ArrowDown')"
(touchend)="handleButtonRelease('ArrowDown')"
>
&#x25BC;
</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()">&times;</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;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
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
);
}
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>v21 Game World</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View file

@ -0,0 +1,9 @@
import {bootstrapApplication, provideProtractorTestingSupport} from '@angular/platform-browser';
import {App} from './app/app';
bootstrapApplication(App, {
providers: [
provideProtractorTestingSupport(), // essential for e2e testing
],
});

View file

@ -0,0 +1,7 @@
{
"description": "Pipes",
"files":[
"!**/*.d.ts",
"!**/*.js"],
"tags": ["pipe"]
}