diff --git a/courses/backend/README.md b/courses/backend/README.md index 0825b4ba..796e91cf 100644 --- a/courses/backend/README.md +++ b/courses/backend/README.md @@ -77,6 +77,7 @@ Total: 18 weeks - [ ] Use logging and debugging tools to monitor and troubleshoot applications - [ ] Connect to databases and implement CRUD operations - [ ] Test APIs using Postman +- [ ] Document APIs using Swagger/OpenAPI ### [Specialist Career Training](/shared-modules/specialist-career-training) diff --git a/courses/frontend/advanced-javascript/week4/README.md b/courses/frontend/advanced-javascript/week4/README.md index 75d2dd93..23fe4226 100644 --- a/courses/frontend/advanced-javascript/week4/README.md +++ b/courses/frontend/advanced-javascript/week4/README.md @@ -18,8 +18,16 @@ By the end of this session, you will be able to: - [ ] Instantiate objects from classes using `new` - [ ] Use Methods and constructors - [ ] Use Static methods - - [ ] Use inheritance with `extends` and `super()` - [ ] Understand the difference between classes vs objects +- [ ] Use **inheritance** and **composition** to share behavior between classes + - [ ] Use inheritance with `extends` and `super()` + - [ ] Recognize when inheritance is a good fit ("is-a" relationship) + - [ ] Use composition ("has-a") as an alternative to inheritance +- [ ] _(optional)_ Recognise common **design patterns** and when to apply them + - [ ] Strategy — swap behavior by passing in a different object + - [ ] Factory — hide object creation complexity behind a function + - [ ] Observer — notify listeners when state changes + - [ ] Singleton — ensure only one instance of a class exists ```js class Comment { diff --git a/courses/frontend/advanced-javascript/week4/assignment.md b/courses/frontend/advanced-javascript/week4/assignment.md index 1374fbd2..415b7ecc 100644 --- a/courses/frontend/advanced-javascript/week4/assignment.md +++ b/courses/frontend/advanced-javascript/week4/assignment.md @@ -4,6 +4,8 @@ For this week's assignment we will create a web application that generates a scr We use [Rapid API](https://rapidapi.com/apishub/api/website-screenshot6/?utm_source=RapidAPI.com%2Fguides&utm_medium=DevRel&utm_campaign=DevRel) to generate a screenshot and the [crudcrud API](https://crudcrud.com/) to save the screenshot. +![Application mockup](./session-materials/assignment-mockup.svg) + ## Technical specifications 1. User can enter a URL for a website and it will send back a screenshot of the website using the website-screenshot API @@ -21,6 +23,244 @@ Look at your interface and think about what parts can be modeled as classes — For the error system, think about what kinds of errors can happen in your app — what if the user submits an empty URL? What if the API returns a bad response? What if the network is down? You might end up with classes like `ValidationError`, `ApiError`, or something else entirely — it's up to you. +--- + +## API Guides + +### The Screenshot API (Rapid API) + +Sign up at [RapidAPI](https://rapidapi.com) and subscribe to the **website-screenshot6** API (free tier is enough). You will get an API key. + +The API takes a website URL and returns **JSON** with a `screenshotUrl` field — a direct link to the generated image you can use in an `` tag. + +```js +async function fetchScreenshot(websiteUrl) { + const response = await fetch( + `https://website-screenshot6.p.rapidapi.com/screenshot?url=${encodeURIComponent(websiteUrl)}&width=1920&height=1080`, + { + method: "GET", + headers: { + "x-rapidapi-host": "website-screenshot6.p.rapidapi.com", + "x-rapidapi-key": "YOUR_API_KEY_HERE", + }, + }, + ); + + if (!response.ok) { + throw new Error(`Screenshot API error: ${response.status}`); + } + + // The response is JSON: { screenshotUrl: "https://..." } + const data = await response.json(); + return data.screenshotUrl; +} +``` + +> **Keep your API key out of git.** Put it in a `secret.js` file and add that file to `.gitignore`. + +--- + +### The crudcrud API + +[crudcrud.com](https://crudcrud.com/) gives you a free, temporary REST API endpoint for storing JSON data. Go to the site and you will get a unique ID — your endpoint will look like: + +```text +https://crudcrud.com/api/YOUR_UNIQUE_ID +``` + +You can create any resource name you like after it, for example `/screenshots`. For this app you need three operations: + +| What you want to do | Method | URL | +| ------------------------- | -------- | --------------------- | +| Get all saved screenshots | `GET` | `.../screenshots` | +| Save a new screenshot | `POST` | `.../screenshots` | +| Delete one screenshot | `DELETE` | `.../screenshots/:id` | + +crudcrud automatically assigns an `_id` field to each item you POST. You will need that `_id` to delete items later. + +#### Save a screenshot + +```js +async function saveScreenshot(websiteUrl, screenshotUrl) { + const response = await fetch( + "https://crudcrud.com/api/YOUR_UNIQUE_ID/screenshots", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ websiteUrl, screenshotUrl }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to save: ${response.status}`); + } + + // crudcrud returns the saved object with its _id + const saved = await response.json(); + return saved; // { _id: "abc123", websiteUrl: "https://example.com", screenshotUrl: "https://..." } +} +``` + +#### Load all screenshots + +```js +async function loadScreenshots() { + const response = await fetch( + "https://crudcrud.com/api/YOUR_UNIQUE_ID/screenshots", + ); + + if (!response.ok) { + throw new Error(`Failed to load: ${response.status}`); + } + + const items = await response.json(); + return items; // Array of { _id, websiteUrl, screenshotUrl } +} +``` + +#### Delete a screenshot + +```js +async function deleteScreenshot(id) { + const response = await fetch( + `https://crudcrud.com/api/YOUR_UNIQUE_ID/screenshots/${id}`, + { method: "DELETE" }, + ); + + if (!response.ok) { + throw new Error(`Failed to delete: ${response.status}`); + } +} +``` + +> **Note:** crudcrud endpoints expire after a few days on the free plan. If your app suddenly stops working, go to crudcrud.com and get a new unique ID. Keep your unique ID in `secret.js` alongside your API key. + +--- + +## Using `render()` — when and how + +The `render()` method is how a class puts itself on the page. The idea: **the class owns its own DOM element**. Call `render()` to create or update that element, then append the returned element somewhere in the DOM. + +Use this base class as a starting point — every UI class in your app should extend it: + +```js +class UIComponent { + constructor() { + this.element = null; + } + + render() { + throw new Error("render() must be implemented by subclass"); + } +} +``` + +A `Screenshot` class is a natural fit here — it holds the website URL, the screenshot image URL, and its crudcrud `_id`, and it knows how to display itself. Think about: + +- What data does it need? (constructor) +- What does its card look like? (render) +- What can it do? (methods like delete) + +```js +class Screenshot extends UIComponent { + constructor(websiteUrl, screenshotUrl, id) { + super(); + this.websiteUrl = websiteUrl; + this.screenshotUrl = screenshotUrl; // direct image URL from the API + this.id = id; // _id from crudcrud — needed to delete later + } + + render() { + // create this.element if it doesn't exist yet, then build the HTML + // use this.screenshotUrl directly as the src + // return this.element so the caller can append it to the page + } + + async delete() { + // call the crudcrud delete function, then remove this.element from the DOM + } +} + +// Usage +const card = new Screenshot( + "https://example.com", + "https://storage.linebot.site/...", + "abc123", +); +document.getElementById("screenshots-list").appendChild(card.render()); +``` + +**When to call `render()`:** + +- Right after creating a new instance — to show it on screen +- After data on the instance changes and the DOM should reflect it + +--- + +## Error handling — when and how + +Not all errors are the same. A user typing nothing in the input is different from the API being down. Custom error classes let you handle each case differently. + +Here is a starting point — adapt it to fit your actual app: + +```js +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = "ValidationError"; + } + toUserMessage() { + return `Invalid input: ${this.message}`; + } +} + +class ApiError extends Error { + constructor(message, statusCode) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } + toUserMessage() { + return `Something went wrong with the request (${this.statusCode}). Try again.`; + } +} +``` + +Use `throw` to signal that something went wrong, and `try/catch` with `instanceof` to handle each type: + +```js +async function handleGenerateScreenshot(websiteUrl) { + try { + if (!websiteUrl || websiteUrl.trim() === "") { + throw new ValidationError("URL cannot be empty"); + } + + const screenshotUrl = await fetchScreenshot(websiteUrl); + // ... display the screenshot using screenshotUrl as src + } catch (error) { + if (error instanceof ValidationError) { + // User made a mistake — show a friendly message next to the input + showError(error.toUserMessage()); + } else if (error instanceof ApiError) { + // API problem — tell the user to try again + showError(error.toUserMessage()); + } else { + // Unexpected error — log it for debugging + console.error(error); + showError("An unexpected error occurred."); + } + } +} +``` + +**Where to use error handling in this app:** + +- When the user submits the form: validate that the URL field is not empty +- When calling the screenshot API: catch network failures or non-2xx responses +- When calling crudcrud (save, load, delete): catch failures and tell the user + +--- + ## Optional Tasks/Assignments > **Note:** Users do not need to be stored in a database or API — just keep them in memory (e.g. an array of instances in your JavaScript). No need to persist them anywhere. @@ -31,4 +271,6 @@ For the error system, think about what kinds of errors can happen in your app 4. Create another user. When saving a screenshot, also save the user email (or another unique identifier). 5. Make sure you only show screenshots that the logged-in user has uploaded. -Keep in mind the API key for the website-screenshot and the uuid for crudcrud should be in a secret.js file which is not committed to git. +--- + +> Keep in mind the API key for the website-screenshot API and the unique ID for crudcrud should be in a `secret.js` file which is not committed to git. diff --git a/courses/frontend/advanced-javascript/week4/session-materials/assignment-mockup.svg b/courses/frontend/advanced-javascript/week4/session-materials/assignment-mockup.svg new file mode 100644 index 00000000..01aba576 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/assignment-mockup.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + Screenshot Saver + + + GENERATE SCREENSHOT + + + + https://example.com + + + + Generate + + + + + + + + + + + + + + + + + + + + + https://example.com + Generated just now + + + Save + + + SAVED SCREENSHOTS + + + + + + + + + + https://google.com + Saved 2 hours ago + + + Delete + + + + + + + + https://github.com + Saved yesterday + + Delete + + + + + + diff --git a/courses/frontend/advanced-javascript/week4/session-materials/code-inspiration.md b/courses/frontend/advanced-javascript/week4/session-materials/code-inspiration.md index 4df83aab..e56f640b 100644 --- a/courses/frontend/advanced-javascript/week4/session-materials/code-inspiration.md +++ b/courses/frontend/advanced-javascript/week4/session-materials/code-inspiration.md @@ -148,6 +148,118 @@ Promise.all([fetch("/a"), fetch("/b")]); Promise.race([fetch("/a"), fetch("/b")]); ``` +## Inheritance + +A child class inherits all properties and methods from a parent. `extends` = "is-a". `super()` calls the parent's constructor — must come before using `this`. + +```js +class Vehicle { + constructor(brand, speed) { + this.brand = brand; + this.speed = speed; + } + move() { + console.log(`${this.brand} is moving`); + } +} + +class Car extends Vehicle { + constructor(brand, speed, doors) { + super(brand, speed); // calls Vehicle's constructor + this.doors = doors; + } + honk() { + console.log("Beep!"); + } +} + +const car = new Car("Tesla", 0, 4); +car.move(); // inherited from Vehicle +car.honk(); // Car's own method +``` + +**When inheritance gets awkward** — the child is forced to break or override parent behavior: + +```js +class Vehicle { + refuel() { + console.log("Filling up the tank..."); + } +} + +// ElectricCar IS A Vehicle, but refuel() makes no sense for it +class ElectricCar extends Vehicle { + refuel() { + throw new Error("I don't use fuel!"); + } +} +``` + +When you find yourself overriding methods just to disable them, that's a sign to use composition instead. + +## Composition + +Instead of inheriting behavior, a class HAS parts. Each part is its own class. This is the "has-a" relationship. + +```js +class Engine { + start() { + console.log("Engine started"); + } +} + +class GPS { + navigate(to) { + console.log(`Navigating to ${to}`); + } +} + +class Car { + constructor(brand) { + this.brand = brand; + this.engine = new Engine(); + this.gps = new GPS(); + } + start() { + this.engine.start(); + } + goTo(address) { + this.gps.navigate(address); + } +} +``` + +**Passing dependencies in** — instead of creating the engine inside, receive it from outside. This lets you swap behaviors without changing the class: + +```js +class ElectricEngine { + start(brand) { + console.log(`${brand}: electric engine humming`); + } +} + +class GasEngine { + start(brand) { + console.log(`${brand}: gas engine roaring`); + } +} + +class Car { + constructor(brand, engine) { + this.brand = brand; + this.engine = engine; // passed in from outside + } + start() { + this.engine.start(this.brand); + } +} + +new Car("Tesla", new ElectricEngine()).start(); // "Tesla: electric engine humming" +new Car("Ford", new GasEngine()).start(); // "Ford: gas engine roaring" +``` + +**Rule of thumb:** favor composition. Use inheritance only when there's a clear, stable "is-a" relationship. + ## (Optional) Extending built-ins: Error and Web Components `Error` is a built-in class; custom errors use `extends` and `super()` like any other subclass. Web Components apply the same “class + lifecycle + HTML” idea to the platform. @@ -187,3 +299,115 @@ try { // customElements.define("my-comment", CommentElement); // ``` + +## (Optional) Design Patterns + +Named solutions to problems that keep showing up. Use these only when they genuinely fit — don't force them. + +### Strategy Pattern + +Swap behavior by passing in a different object. This is the composition idea taken one step further. + +```js +const electric = { + start(b) { + console.log(`${b}: humming`); + }, +}; +const gas = { + start(b) { + console.log(`${b}: roaring`); + }, +}; +const hybrid = { + start(b) { + console.log(`${b}: both!`); + }, +}; + +// Same class, different strategy → different behavior +new Car("Tesla", electric).start(); +new Car("Ford", gas).start(); +new Car("Toyota", hybrid).start(); +``` + +**When to use it:** multiple ways to do the same thing (sorting, validation, auth); you want to switch behavior without modifying the class itself. + +### Factory Pattern + +A function that creates objects for you — hides `new` and setup logic from the caller. + +```js +function createCar(type, brand) { + const engines = { + electric: { + start(b) { + console.log(`${b}: humming`); + }, + }, + gas: { + start(b) { + console.log(`${b}: roaring`); + }, + }, + }; + return new Car(brand, engines[type]); +} + +const tesla = createCar("electric", "Tesla"); +const ford = createCar("gas", "Ford"); +``` + +**When to use it:** object creation is complex (many params, config, dependencies); you want to centralize and hide construction details. + +### Observer Pattern + +"When something happens, notify everyone who cares." This is how `addEventListener`, Node's `EventEmitter`, and most UI frameworks work under the hood. + +```js +class Order { + constructor() { + this.listeners = []; + this.status = "pending"; + } + + onChange(fn) { + this.listeners.push(fn); + } + + updateStatus(newStatus) { + this.status = newStatus; + this.listeners.forEach((fn) => fn(this.status)); + } +} + +const order = new Order(); +order.onChange((s) => console.log(`Customer notified: ${s}`)); +order.onChange((s) => console.log(`Driver notified: ${s}`)); +order.updateStatus("ready"); // both callbacks fire +``` + +### Singleton Pattern + +Only one instance ever exists. Every call to `new` returns the same object. + +```js +class Database { + constructor(url) { + if (Database.instance) return Database.instance; + this.url = url; + this.connected = false; + Database.instance = this; + } + connect() { + this.connected = true; + } +} + +const db1 = new Database("postgres://..."); +const db2 = new Database("mysql://..."); +console.log(db1 === db2); // true — same instance! +``` + +**Good for:** DB connections, config, logging, caches — things you truly need only one of. +**Use sparingly:** singletons are global state in disguise. They make testing harder and hide dependencies. diff --git a/courses/frontend/advanced-javascript/week4/session-materials/exercises.md b/courses/frontend/advanced-javascript/week4/session-materials/exercises.md index a375086f..64c32d47 100644 --- a/courses/frontend/advanced-javascript/week4/session-materials/exercises.md +++ b/courses/frontend/advanced-javascript/week4/session-materials/exercises.md @@ -2,17 +2,19 @@ Work through these in order. -## 1. Create a user class +## 1. User class with DOM rendering -The class should have 2 properties: `firstName` and `lastName`. Hint: Use `this` and `constructor`. +### 1. Create a user class -## 2. Create an instance of the class +Create a `User` class with 2 properties: `firstName` and `lastName`. Hint: use `this` and `constructor`. + +### 2. Create an instance of the class Use the `new` keyword and assign the instance in a variable. Add a **`renderUserCard(user)`** function that accepts a **`User`** instance and renders a user card on the page (e.g. a `div` with `firstName` and `lastName`). -## 3. Create a class method +### 3. Create a class method 1. Add **`getFullName`**: it should return the combined first and last name of the user. Use string concatenation or template literals and **`this`** to read the properties. @@ -20,7 +22,7 @@ Add a **`renderUserCard(user)`** function that accepts a **`User`** instance and 3. Call **`myUser.render()`** so the card appears on the page (you can stop using **`renderUserCard`** once this works). -## 4. Creating a CV class +## 2. Creating a CV class The CV that we will be making uses three classes: `Job`, `Education` and `CV`. The `CV` class we have made for you (with some missing functionality). The `Job` and `Education` classes you need to create. @@ -87,3 +89,53 @@ class CV { ### Part 4 Add a method to the `CV` class called `renderCV()`. This method should render out the CV using HTML. Make sure, that view updates, when data is changed. + +## 3. Design Challenge: FoodDash + +You're building a food delivery app. Customers browse restaurants, add items to their order, and a driver picks it up and delivers it. + +**Rules:** paper only — no code yet! + +For each class you identify, write down: + +- Its name +- Its properties +- Its methods +- How it relates to the other classes + +Think about: + +1. What classes do you need? +2. What properties does each class have? +3. What methods does each class need? +4. How do the classes relate to each other? +5. Does anything share behavior? How would you handle that? + +When done, compare your design with others: which classes did different people pick? Did anyone make `Driver extends User`? How did you handle the `Order`/`Restaurant` relationship? + +## Bonus: Build FoodDash + +Now that you've designed FoodDash on paper, build it in code. + +1. Create a `Restaurant` class with a `name` property and a `menu` property (array of items, each with a `name` and `price`). +2. Create an `Order` class that takes an array of items and a `Restaurant` instance. + - Add an `addItem(item)` method and a `removeItem(item)` method. + - Add an `async calculateTotal()` method that sums the prices of all items in the order. +3. Create a `User` class that receives a `name`, `email`, and `role` object via the constructor. The role object must have a `perform(name)` method. + - Add a `doWork()` method that calls `this.role.perform(this.name)`. +4. Create two role objects (`customerRole` and `driverRole`), each with a `perform(name)` method that logs what that role does. + +**Bonus:** Add a `static Order.sortByTotal(orders)` method that sorts an array of orders by total price. + +## Challenge: Monster Arena + +A turn-based monster battle game — design and OOP in action. + +You have a starter project in [`./oop-monster-arena/`](./oop-monster-arena/). Follow the instructions in its README. + +The challenge covers: + +- Modeling game entities as classes (`Monster`, `Arena`, `Attack`) +- Using composition to give monsters different attack strategies +- Inheritance for shared monster behavior +- Turn-based game loop logic diff --git a/courses/frontend/advanced-javascript/week4/session-materials/js_oop_classes.pdf b/courses/frontend/advanced-javascript/week4/session-materials/js_oop_classes.pdf new file mode 100644 index 00000000..0e566337 Binary files /dev/null and b/courses/frontend/advanced-javascript/week4/session-materials/js_oop_classes.pdf differ diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/.gitignore b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/.gitignore new file mode 100644 index 00000000..62ad6bc9 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/.gitignore @@ -0,0 +1,4 @@ +.claude +node_modules +dist +docs \ No newline at end of file diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/ABILITY_EXAMPLES.md b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/ABILITY_EXAMPLES.md new file mode 100644 index 00000000..d29150ac --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/ABILITY_EXAMPLES.md @@ -0,0 +1,158 @@ +# Ability Examples + +A reference of ability ideas you can build from. + +--- + +## The two rules to remember + +> **Higher `amount` = lower trigger chance.** The budget is fixed, so if you return `50`, it fires ~30% of the time. If you return `10`, it fires ~100% (or 80% for HealAbility). Big swings are rare; small effects are reliable. + +> **Fewer charges = higher trigger chance.** Limited-charge abilities get a budget bonus to compensate. A 1-charge ability fires up to 5× more reliably than an unlimited one at the same amount — so a single-use nuke can be near-guaranteed. + +--- + +## Budget & trigger chance + +Every ability type has a fixed **budget**. The trigger chance is calculated automatically: + +```text +triggerChance = (budget × chargeMultiplier) / amount (capped at 100%) +``` + +### Base budgets (unlimited / ≥ 5 charges) + +| Type | Budget | amount 8 | amount 15 | amount 20 | amount 30 | amount 50 | +| ------------- | ------ | -------- | --------- | --------- | --------- | --------- | +| DamageAbility | 15 | 100% | 100% | 75% | 50% | 30% | +| HealAbility | 12 | 100% | 80% | 60% | 40% | 24% | +| ArmorAbility | 8 | 100% | 53% | 40% | 27% | 16% | + +### Charge multiplier + +Charges are part of the budget. An ability with limited charges can fire fewer times per bout, so each trigger is worth more — the budget scales up automatically. + +```text +chargeMultiplier = 5 / min(charges, 5) +``` + +| charges | multiplier | effective DamageAbility budget | +| ------- | ---------- | ------------------------------ | +| ∞ or ≥5 | ×1.0 | 15 | +| 3 | ×1.67 | 25 | +| 2 | ×2.5 | 37.5 | +| 1 | ×5.0 | 75 | + +**Examples with charges:** + +- `new DamageAbility(30)` → 50% trigger, fires every turn it can (unlimited) +- `new DamageAbility(30, 3)` → **83%** trigger, fires at most 3 times +- `new DamageAbility(30, 1)` → **100%** trigger, fires exactly once (guaranteed) +- `new HealAbility(20)` → 60% trigger, unlimited +- `new HealAbility(20, 2)` → **100%** trigger, fires at most twice +- `new ArmorAbility(20)` → 40% trigger, unlimited +- `new ArmorAbility(20, 1)` → **100%** trigger, fires exactly once (guaranteed) + +When `activate()` returns a **variable** amount, the charge multiplier still applies and the chance is recalculated each turn based on whatever value you return. + +--- + +## DamageAbility + +Deals extra damage to the opponent each time it triggers. + +**Flat bonus** — simplest case, always hits for the same extra damage. + +```text +activate() returns 20 — triggers 75% of the time. +``` + +**Rage** — tracks how many hits you've taken; the more damage received, the harder the next hit. + +```text +activate() returns 10 + (hitsReceived × 5). +Each hit you absorb adds 5 to the next ability trigger. +describe() says "Warlord retaliates with X fury damage!" +``` + +**Finishing blow** — checks opponent's HP; explodes when they're low. + +```text +activate() returns 50 if opponent.hp.current < 30, else 10. +When the enemy is near death, you deal a huge spike. +``` + +**Berserk (limited charges)** — 3 charges only, but each one hits hard. + +```text +new DamageAbility(40, 3) — triggers ~63% each turn (budget ×1.67), 3 uses total. +``` + +--- + +## HealAbility + +Restores HP to the attacker each time it triggers. + +**Steady regeneration** — modest heal every few turns. + +```text +new HealAbility(15) — heals 15 HP, triggers 80% of the time. +``` + +**Desperate surge** — heals more when critically low. + +```text +activate() returns 40 if attacker.hp.current < 20, else 8. +Nearly dead? Panic-heal for a large burst. +``` + +**Vampiric strike** — heals based on how hard you attack. + +```text +activate() returns attacker.attackPower / 2. +Steals life proportional to your own strength. +``` + +--- + +## ArmorAbility + +Reduces the opponent's `attackPower` permanently for the rest of the bout. + +**Steady debuff** — grinds down the enemy's attack over time. + +```text +new ArmorAbility(5) — reduces opponent attack by 5, triggers 100% of the time. +By turn 4 the enemy hits much weaker. +``` + +**Intimidation (one shot)** — big one-time armor shred on first contact. + +```text +new ArmorAbility(20, 1) — 1 charge, reduces attack by 20 (100% guaranteed, budget ×5). +One scary moment early that sets the tone for the whole fight. +``` + +--- + +## Ability + onTakeDamage (ability swap) + +The most advanced pattern — your monster **changes ability mid-fight** when hurt. + +**Cornered animal** — starts defensive, switches to offense when badly hurt. + +```text +Start with a HealAbility. +In onTakeDamage(): when HP drops below 50%, swap this.ability to a DamageAbility. +describe() says "Cornered, X fights back with desperation!" +``` + +**Growing counter** — tracks damage taken and scales the ability accordingly. + +```text +this.hitsReceived = 0 +onTakeDamage() increments hitsReceived +activate() returns hitsReceived × 3 +The ability grows stronger the longer the fight goes on. +``` diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/README.md b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/README.md new file mode 100644 index 00000000..a5ceef56 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/README.md @@ -0,0 +1,156 @@ +# Monster Arena ⚔️ + +_Created by Paolo Bozzini for HackYourFuture Denmark — modified fork from [PaoloBozzini/oop-monster-arena](https://github.com/PaoloBozzini/oop-monster-arena/tree/main)._ + +Each group builds a custom monster. At the end of the session, **all monsters fight in a round-robin tournament** — every monster vs every other monster, once. The arena animates each battle in real time in the browser, with a live results matrix and a 1 000-simulation Monte Carlo win-rate chart. + +--- + +## What you'll build + +A JavaScript class that extends `Monster`. Your monster has: + +- A **name**, **health points**, and **attack power** (you choose the numbers) +- A **special ability** — something creative that fires after each normal attack + +The base `Monster` class uses a `HealthComponent` for HP management — this is an example of **composition** (has-a relationship). Your subclass is an example of **inheritance** (is-a relationship). You'll see both OOP patterns in action. + +**Stat budget:** your combined score — `health + attackPower × 3` — must be ≤ 300. Attack power is more expensive than raw HP (3 pts each vs 1 pt). Dragon uses 160 HP + 30 attack × 3 = 250 pts. Exceeding the budget throws an error immediately so you can fix it fast. + +--- + +## Your workflow + +### 1. Get the project + +Clone or download this project, then create your own GitHub repository and push it there: + +```bash +cd oop-monster-arena +git remote set-url origin +git push -u origin main +npm install +``` + +### 2. Copy the template + +```bash +cp src/monsters/your-monster.js src/monsters/YourMonsterName.js +``` + +Open the new file. Read `src/monsters/Dragon.js` for a full worked example, then fill in your stats and pick an ability to inject. + +### 3. Add your monster's image + +- Find or generate an image for your monster (Google Images, DALL·E, Midjourney…) +- The filename **must exactly match your class name** (case-sensitive!) +- If your image is a **PNG**: save it as `assets/monsters/YourMonsterName.png` — done! +- If your image is a **JPG or other format**: save it with the right extension, then add this to your class: + ```js + get imagePath() { return 'assets/monsters/YourMonsterName.jpg'; } + ``` + +### 4. Test your monster + +```bash +npm test # tests src/monsters/your-monster.js +npm test src/monsters/YourMonsterName.js # tests your renamed file +``` + +You should see green checks for all tests. Fix anything red before moving on. + +--- + +## What you write + +Two classes in one file — an ability and a monster: + +```js +// Your ability — extends one of the three base types +class MyAbility extends DamageAbility { + activate(attacker, opponent) { + /* return damage amount */ + } + describe(attacker, amount) { + /* return a log string */ + } +} + +// Your monster — extends Monster, injects your ability +export class YourMonster extends Monster { + constructor() { + super("Name", health, attackPower, new MyAbility(amount)); + } + onTakeDamage(amount) { + /* optional — react when hit, swap stats or ability */ + } +} +``` + +## Monster hooks + +| Override | When it's called | What to do | +| ---------------------- | -------------------------- | -------------------------------------------- | +| `onTakeDamage(amount)` | Every time you take damage | Change stats, swap ability — no return value | + +## Ability base types + +Extend one and override `activate()`. `triggerChance` is derived automatically as `budget / amount` — you never set it directly. + +| Base type | Budget | Default effect | +| --------------- | ------ | ------------------------------------- | +| `DamageAbility` | 15 | `opponent.takeDamage(this.amount)` | +| `HealAbility` | 12 | `attacker.hp.heal(this.amount)` | +| `ArmorAbility` | 8 | `opponent.attackPower -= this.amount` | + +Example: `new MyAbility(30)` extends `DamageAbility` → triggerChance = 15/30 = 50%. + +See `ABILITY_EXAMPLES.md` for full examples including the charge system. + +--- + +## Study guide + +| File | Read it? | Edit it? | +| ------------------------------ | -------------------------------------- | ------------------------ | +| `src/core/health.js` | ✅ Yes — see how **composition** works | ❌ No | +| `src/core/monster.js` | ✅ Yes — understand the base class | ❌ No | +| `src/core/ability.js` | ✅ Yes — see the three ability types | ❌ No | +| `src/monsters/Dragon.js` | ✅ Yes — your **reference example** | ❌ No | +| `src/monsters/your-monster.js` | ✅ Yes | ✅ **This is your file** | +| `src/arena.js` | Optional | ❌ No | +| `src/ui.js` | Optional | ❌ No | + +--- + +## Running the arena + +```bash +npm install +npm run dev +``` + +Open the URL shown in the terminal. To add more monsters to the tournament, update `src/main.js`: + +```js +// Step 1: add an import at the top +import { Hydra } from "./monsters/Hydra.js"; +import { Werewolf } from "./monsters/Werewolf.js"; + +// Step 2: add a new instance to the array +const monsters = [new Dragon(), new Hydra(), new Werewolf()]; +``` + +Vite hot-reloads automatically — save `main.js` and the browser updates instantly. + +--- + +## Checklist + +- [ ] Cloned/downloaded the project, created own repo, ran `npm install` +- [ ] Copied and renamed `your-monster.js` +- [ ] Renamed the class to match the filename (case-sensitive!) +- [ ] Called `super()` with name, health, attack power, and an ability (budget: `health + attackPower × 3 ≤ 300`) +- [ ] Passed a `DamageAbility`, `HealAbility`, or `ArmorAbility` as the 4th argument +- [ ] Added an image to `assets/monsters/` (exact class name as filename) +- [ ] Ran `npm test` — all checks pass diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/.gitkeep b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Dragon.svg b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Dragon.svg new file mode 100644 index 00000000..b127eaa8 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Dragon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Goblin.svg b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Goblin.svg new file mode 100644 index 00000000..c923c1ab --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Goblin.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Troll.svg b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Troll.svg new file mode 100644 index 00000000..d8578be1 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/assets/monsters/Troll.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/index.html b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/index.html new file mode 100644 index 00000000..7b6150ee --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/index.html @@ -0,0 +1,124 @@ + + + + + + + + + Monster Arena + + +
+

⚔️ Monster Arena

+

+ Waiting for the tournament to start... +

+ +
+ +
+ + +
+
+ + +
+

+
+
+
+ +
+ + +
+
    +
    + + +
    +
    + + +
    +

    +
    +
    +
    + +
    +
    + + + + + + + + +
    +

    Round Robin

    +
    +
    +
    +
    + + + + diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package-lock.json b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package-lock.json new file mode 100644 index 00000000..33dfa9fe --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package-lock.json @@ -0,0 +1,1013 @@ +{ + "name": "monster-arena", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monster-arena", + "version": "1.0.0", + "dependencies": { + "gsap": "^3.12.5" + }, + "devDependencies": { + "prettier": "^3.8.1", + "vite": "^5.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package.json b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package.json new file mode 100644 index 00000000..e9354a54 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/package.json @@ -0,0 +1,18 @@ +{ + "name": "monster-arena", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "node test.js" + }, + "dependencies": { + "gsap": "^3.12.5" + }, + "devDependencies": { + "prettier": "^3.8.1", + "vite": "^5.2.0" + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/arena.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/arena.js new file mode 100644 index 00000000..7fc05b38 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/arena.js @@ -0,0 +1,258 @@ +// arena.js — tournament engine. Students do not edit this file. +// +// Two-phase design: +// 1. Simulate all bouts instantly (deterministic, no async) +// 2. Play back the event log with delays (async, drives the UI) + +const ATTACK_DELAY = 400; +const BOUT_DELAY = 1800; +const SPECIAL_DELAY = 300; + +function emit(name, detail) { + document.dispatchEvent(new CustomEvent(`arena:${name}`, { detail })); +} + +/** + * Run a Monte Carlo simulation over many tournaments and return win-rate stats. + * Uses a lightweight simulation that skips event building for speed. + * + * @param {Monster[]} monsters + * @param {number} [iterations=1000] + * @returns {{ name: string, winRate: number, avgWins: number }[]} + * Sorted by winRate descending. winRate = fraction of tournaments won (0–1). + * avgWins = average bout wins per tournament. + */ +export function monteCarlo(monsters, iterations = 1000) { + const ids = monsters.map((m) => m.id); + const tournamentWins = Object.fromEntries(ids.map((id) => [id, 0])); + const boutWinTotals = Object.fromEntries(ids.map((id) => [id, 0])); + const boutsPerTournament = (monsters.length * (monsters.length - 1)) / 2; + + for (let iter = 0; iter < iterations; iter++) { + const wins = Object.fromEntries(ids.map((id) => [id, 0])); + + for (let i = 0; i < monsters.length; i++) { + for (let j = i + 1; j < monsters.length; j++) { + const a = monsters[i]; + const b = monsters[j]; + a.reset(); + b.reset(); + const winner = _simulateBoutFast(a, b); + if (winner) wins[winner.id]++; + } + } + + monsters.forEach((m) => m.reset()); + + // Credit tournament win to whoever had the most bout wins. + const maxWins = Math.max(...Object.values(wins)); + const champIds = ids.filter((id) => wins[id] === maxWins); + // Distribute credit evenly among tied champions. + champIds.forEach((id) => { + tournamentWins[id] += 1 / champIds.length; + }); + ids.forEach((id) => { + boutWinTotals[id] += wins[id]; + }); + } + + return monsters + .map((m) => ({ + name: m.name, + winRate: tournamentWins[m.id] / iterations, + avgWins: boutWinTotals[m.id] / iterations, + maxWins: boutsPerTournament, + })) + .sort((a, b) => b.winRate - a.winRate); +} + +/** Lightweight bout simulation — no event recording, just returns the winner. */ +function _simulateBoutFast(a, b) { + const goesFirst = Math.random() < 0.5 ? a : b; + const goesSecond = goesFirst === a ? b : a; + let turn = 0; + + while (a.isAlive() && b.isAlive() && turn < 200) { + const attacker = turn % 2 === 0 ? goesFirst : goesSecond; + const defender = turn % 2 === 0 ? goesSecond : goesFirst; + attacker.attack(defender); + turn++; + } + + if (a.isAlive() && !b.isAlive()) return a; + if (b.isAlive() && !a.isAlive()) return b; + return null; // draw +} + +function simulateBout(a, b) { + const events = []; + let turn = 0; + + // Coin flip — first attacker varies each bout even with identical builds. + const goesFirst = Math.random() < 0.5 ? a : b; + const goesSecond = goesFirst === a ? b : a; + + // 200-turn cap prevents infinite loops with immortal builds. + while (a.isAlive() && b.isAlive() && turn < 200) { + const attacker = turn % 2 === 0 ? goesFirst : goesSecond; + const defender = turn % 2 === 0 ? goesSecond : goesFirst; + + // Snapshot HP before the attack so the attack event reflects only normal + // damage, and the special event reflects only the special's contribution. + const defenderHpBefore = defender.hp.current; + + const result = attacker.attack(defender); + + // HP after just the normal attack (before the special was applied). + // attack() applies both atomically, so we reconstruct the mid-point: + // normal damage is clamped at 0 by HealthComponent, mirror that here. + const defenderHpAfterAttack = Math.max(0, defenderHpBefore - result.damage); + const defenderPctAfterAttack = Math.round( + (defenderHpAfterAttack / defender.hp.max) * 100, + ); + + events.push({ + type: "attack", + attackerName: attacker.name, + defenderName: defender.name, + damage: result.damage, + defenderHp: defenderHpAfterAttack, + defenderMaxHp: defender.hp.max, + defenderPct: defenderPctAfterAttack, + attackerSide: attacker === a ? "left" : "right", + }); + + if (result.special) { + // defender.hp.current now reflects attack + special; attacker.hp.current + // reflects any self-heal from the special. + events.push({ + type: "special", + attackerName: attacker.name, + description: result.special, + defenderHp: defender.hp.current, + defenderMaxHp: defender.hp.max, + defenderPct: defender.hp.percentage, + attackerHp: attacker.hp.current, + attackerMaxHp: attacker.hp.max, + attackerPct: attacker.hp.percentage, + attackerSide: attacker === a ? "left" : "right", + }); + } + + turn++; + } + + let winner = null; + if (a.isAlive() && !b.isAlive()) winner = a; + else if (b.isAlive() && !a.isAlive()) winner = b; + + events.push({ + type: "boutEnd", + winnerId: winner ? winner.id : null, + winnerName: winner ? winner.name : null, + isDraw: winner === null, + }); + return { events, winner, firstAttacker: goesFirst }; +} + +/** + * Run a round-robin tournament — every monster fights every other monster once. + * @param {Monster[]} monsters + */ +export function tournament(monsters) { + if (monsters.length < 2) { + const label = document.getElementById("bout-label"); + if (label) + label.textContent = + "⚠️ Need at least 2 monsters to start the tournament!"; + console.error("Need at least 2 monsters for a tournament."); + return; + } + + const allEvents = []; + const wins = Object.fromEntries(monsters.map((m) => [m.id, 0])); + + for (let i = 0; i < monsters.length; i++) { + for (let j = i + 1; j < monsters.length; j++) { + const a = monsters[i]; + const b = monsters[j]; + + a.reset(); + b.reset(); + + // Simulate before building boutStart so firstAttacker is available. + const { events, winner, firstAttacker } = simulateBout(a, b); + + allEvents.push({ + type: "boutStart", + aId: a.id, + bId: b.id, + aName: a.name, + bName: b.name, + aImagePath: a.imagePath, + bImagePath: b.imagePath, + aMaxHp: a.hp.max, + bMaxHp: b.hp.max, + firstAttacker: firstAttacker.name, + }); + + allEvents.push(...events); + if (winner) wins[winner.id]++; + + allEvents.push({ type: "boutPause" }); + } + } + + const leaderboard = monsters + .map((m) => ({ name: m.name, wins: wins[m.id] })) + .sort((a, b) => b.wins - a.wins); + + allEvents.push({ type: "tournamentEnd", leaderboard }); + + monsters.forEach((m) => m.reset()); + + playback(allEvents); +} + +let _pendingTimeouts = []; + +export function cancelTournament() { + _pendingTimeouts.forEach(clearTimeout); + _pendingTimeouts = []; +} + +function playback(events) { + _pendingTimeouts = []; + let delay = 0; + + for (const event of events) { + switch (event.type) { + case "boutStart": + _pendingTimeouts.push( + setTimeout(() => emit("boutStart", event), delay), + ); + delay += BOUT_DELAY; + break; + case "attack": + _pendingTimeouts.push(setTimeout(() => emit("attack", event), delay)); + delay += ATTACK_DELAY; + break; + case "special": + _pendingTimeouts.push(setTimeout(() => emit("special", event), delay)); + delay += SPECIAL_DELAY; + break; + case "boutEnd": + _pendingTimeouts.push(setTimeout(() => emit("boutEnd", event), delay)); + delay += BOUT_DELAY; + break; + case "boutPause": + delay += BOUT_DELAY; + break; + case "tournamentEnd": + _pendingTimeouts.push( + setTimeout(() => emit("tournamentEnd", event), delay), + ); + break; + } + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/ability.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/ability.js new file mode 100644 index 00000000..3aafe1c8 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/ability.js @@ -0,0 +1,321 @@ +/** + * ability.js — three base ability types students extend to write their own. + * + * ## How abilities work + * After every normal attack, the arena engine calls `ability.tryActivate()`. + * If the roll succeeds and charges remain, it calls `activate()` to get the + * effect amount, applies it via `_applyEffect()`, then calls `describe()` to + * produce the battle log entry. + * + * You only need to override two methods: + * - `activate(attacker, opponent)` — return the effect amount for this turn. + * - `describe(attacker, amount)` — return the battle log string. + * + * ## Trigger chance and the budget rule + * Each ability type has a fixed budget. Trigger chance is derived automatically: + * + * triggerChance = (budget × chargeMultiplier) / amount + * + * A higher `amount` means a stronger hit but a rarer trigger — the trade-off + * is built in. You never set triggerChance directly. + * + * ## Charges and the budget + * Charges are part of the budget. A limited-charge ability can fire at most N times + * per bout, so each trigger is worth more — the budget scales up to compensate. + * The multiplier is based on a reference of 5 expected triggers per bout (what an + * unlimited ability would fire in a typical fight): + * + * chargeMultiplier = 5 / min(charges, 5) + * + * | charges | multiplier | DamageAbility budget | + * |------------|------------|----------------------| + * | ∞ (or ≥ 5) | ×1.0 | 15 | + * | 3 | ×1.67 | 25 | + * | 2 | ×2.5 | 37.5 | + * | 1 | ×5.0 | 75 | + * + * A single-charge DamageAbility with amount ≤ 75 is guaranteed to fire (100%). + * A 2-charge HealAbility at amount 30 now has 75% trigger chance instead of 40%. + * + * | Type | Budget | amount 10 | amount 20 | amount 30 | + * |---------------|--------|-----------|-----------|-----------| + * | DamageAbility | 15 | 100% | 75% | 50% | + * | HealAbility | 12 | 100% | 60% | 40% | + * | ArmorAbility | 8 | 80% | 40% | 27% | + * (table above shows unlimited / ≥ 5 charges; finite charges raise all percentages) + * + * ## Charges (optional) + * Pass a second argument to limit how many times the ability can fire per bout. + * Omit it (or pass `Infinity`) for unlimited uses. + * + * ```js + * new DamageAbility(40, 2) // 2 charges → budget ×2.5 → 93% trigger chance + * new DamageAbility(40) // unlimited → budget ×1 → 37% trigger chance + * new HealAbility(20) // unlimited uses, 60% trigger + * ``` + * + * ## Dynamic amount + * `activate()` can return a different amount each turn based on game state. + * Trigger chance is recalculated live from whatever value you return. + * + * ```js + * activate(attacker, opponent) { + * // grows stronger the more hits the attacker has taken + * return this.amount + attacker.hitsTaken * 5; + * } + * ``` + * + * ## Quick-start example + * ```js + * import { DamageAbility } from '../core/ability.js'; + * + * class VenomStrike extends DamageAbility { + * activate(attacker, opponent) { + * return 25; // fixed 25 damage → triggerChance = 15/25 = 60% + * } + * describe(attacker, amount) { + * return `${attacker.name} injects venom for ${amount} damage!`; + * } + * } + * ``` + */ + +export class Ability { + /** + * @param {number} amount + * The base effect amount (damage dealt, HP healed, or attack reduced). + * Must be a positive integer. Controls trigger chance: higher = rarer. + * @param {number} [charges=Infinity] + * Maximum number of times this ability can fire per bout. + * Use a positive integer to limit uses; omit for unlimited. + */ + constructor(amount, charges = Infinity) { + if (!Number.isInteger(amount) || amount < 1) + throw new Error( + `[Ability] amount must be an integer ≥ 1. Got: ${amount}`, + ); + if (charges !== Infinity && (!Number.isInteger(charges) || charges < 1)) + throw new Error( + `[Ability] charges must be a positive integer. Got: ${charges}`, + ); + + /** Base effect amount. Readable in `activate()` as `this.amount`. */ + this.amount = amount; + /** Maximum charges per bout (`Infinity` = unlimited). */ + this.chargesMax = charges; + /** Charges remaining this bout. Decrements on each successful trigger. */ + this.chargesLeft = charges; + } + + /** @protected Budget constant — overridden by each concrete subclass. */ + get _budget() { + return 15; + } + + /** + * Budget multiplier derived from charge count. + * + * Limited-charge abilities fire fewer times per bout, so each trigger is + * worth more. The multiplier scales the budget up to compensate, using 5 + * expected triggers as the reference for an unlimited ability: + * + * chargeMultiplier = 5 / min(charges, 5) + * + * Abilities with ≥ 5 charges (or ∞) receive no bonus (multiplier = 1). + * + * @protected + */ + get _chargeMultiplier() { + const reference = 5; + const effective = + this.chargesMax === Infinity + ? reference + : Math.min(this.chargesMax, reference); + return reference / effective; + } + + /** + * Probability that this ability fires on any given turn (0–1). + * Derived automatically from the budget, charge multiplier, and the amount + * returned by `activate()`. You never set this directly. + */ + get triggerChance() { + return Math.min(1, (this._budget * this._chargeMultiplier) / this.amount); + } + + /** + * Override — return the effect amount to apply this turn. + * + * The engine calls this before the trigger roll so it can recalculate + * the chance from whatever value you return. Return a higher value for + * a stronger but rarer trigger; return a lower value for a weaker but + * more frequent one. + * + * You have full access to both monsters' state here. + * + * @param {Monster} attacker The monster using this ability. + * @param {Monster} opponent The monster being targeted. + * @returns {number} A positive integer — the effect amount for this turn. + * + * @example + * // Scales with hits taken — gets stronger (and rarer) as the fight goes on + * activate(attacker, opponent) { + * return this.amount + attacker.hitsTaken * 4; + * } + */ + activate(attacker, opponent) { + return this.amount; + } + + /** + * Override — return the battle log string shown when the ability fires. + * + * Called only after the trigger roll succeeds and the effect has been applied. + * The `amount` parameter is the exact value returned by `activate()` this turn, + * so your message can reflect dynamic values. + * + * @param {Monster} attacker The monster that used the ability. + * @param {number} amount The effect amount applied this turn. + * @returns {string} A short, flavourful description for the battle log. + * + * @example + * describe(attacker, amount) { + * const fury = attacker.hitsTaken >= 3 ? ' ENRAGED' : ''; + * return `${attacker.name}${fury} strikes for ${amount} bonus damage!`; + * } + */ + describe(attacker, amount) { + return null; + } + + /** @protected Applies the effect. Overridden by each concrete subclass. */ + _applyEffect(attacker, opponent, amount) {} + + /** + * Called by the engine after every attack. Handles the trigger roll, + * charge tracking, and effect application. Do not call or override this. + * + * @param {Monster} attacker + * @param {Monster} opponent + * @returns {string|null} The battle log string, or null if the ability did not fire. + */ + tryActivate(attacker, opponent) { + if (this.chargesLeft <= 0) return null; + + const amount = this.activate(attacker, opponent); + if (!Number.isInteger(amount) || amount < 1) + throw new Error( + `[Ability] activate() must return an integer ≥ 1. Got: ${amount}`, + ); + + // triggerChance × amount = budget × chargeMultiplier (always) + const chance = Math.min( + 1, + (this._budget * this._chargeMultiplier) / amount, + ); + if (Math.random() > chance) return null; + + if (this.chargesMax !== Infinity) this.chargesLeft--; + this._applyEffect(attacker, opponent, amount); + return this.describe(attacker, amount); + } + + /** + * Resets `chargesLeft` to `chargesMax`. Called automatically by + * `Monster.reset()` between bouts — you do not need to call this yourself + * unless you have swapped abilities (see Monster.reset() docs). + */ + reset() { + this.chargesLeft = this.chargesMax; + } +} + +// ── Concrete base types ─────────────────────────────────────────────────────── +// +// Extend one of these three. Override activate() and describe(). +// The _budget and _applyEffect() are already set correctly for each type. + +/** + * Deals bonus damage to the opponent. + * + * Budget: 15 + * Default describe: "[Name] deals N bonus damage!" + * + * @example + * class FireBlast extends DamageAbility { + * activate(attacker, opponent) { return 40; } // 40 dmg → 37% trigger + * describe(attacker, amount) { return `${attacker.name} blasts for ${amount}!`; } + * } + */ +export class DamageAbility extends Ability { + get _budget() { + return 15; + } + _applyEffect(attacker, opponent, amount) { + opponent.takeDamage(amount); + } + describe(attacker, amount) { + const note = + this.chargesMax !== Infinity ? ` (${this.chargesLeft} charges left)` : ""; + return `${attacker.name} deals ${amount} bonus damage${note}!`; + } +} + +/** + * Heals the attacker (self-heal). + * + * Budget: 12 + * Default describe: "[Name] heals N HP!" + * + * Healing cannot exceed max HP — HealthComponent clamps automatically. + * + * @example + * class Regenerate extends HealAbility { + * activate(attacker, opponent) { return 20; } // 20 HP → 60% trigger + * describe(attacker, amount) { return `${attacker.name} regenerates ${amount} HP.`; } + * } + */ +export class HealAbility extends Ability { + get _budget() { + return 12; + } + _applyEffect(attacker, opponent, amount) { + attacker.hp.heal(amount); + } + describe(attacker, amount) { + const note = + this.chargesMax !== Infinity ? ` (${this.chargesLeft} charges left)` : ""; + return `${attacker.name} heals ${amount} HP${note}!`; + } +} + +/** + * Reduces the opponent's `attackPower` for the remainder of the bout. + * + * Budget: 8 + * Default describe: "[Name] reduces opponent's attack by N!" + * + * `attackPower` is clamped to a minimum of 1 so the opponent can always deal + * at least 1 damage. The debuff is automatically reversed at the start of + * the next bout when `Monster.reset()` restores the original `attackPower`. + * + * @example + * class Weaken extends ArmorAbility { + * activate(attacker, opponent) { return 12; } // -12 atk → 67% trigger + * describe(attacker, amount) { return `${attacker.name} weakens the foe by ${amount}!`; } + * } + */ +export class ArmorAbility extends Ability { + get _budget() { + return 8; + } + _applyEffect(attacker, opponent, amount) { + // Monster.reset() restores attackPower to its initial value between bouts. + opponent.attackPower = Math.max(1, opponent.attackPower - amount); + } + describe(attacker, amount) { + const note = + this.chargesMax !== Infinity ? ` (${this.chargesLeft} charges left)` : ""; + return `${attacker.name} reduces opponent's attack by ${amount}${note}!`; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/health.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/health.js new file mode 100644 index 00000000..85357ef1 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/health.js @@ -0,0 +1,45 @@ +// health.js — manages a monster's hit points. +// Example of COMPOSITION: Monster delegates all HP logic to this object. + +export class HealthComponent { + /** @param {number} maxHealth */ + constructor(maxHealth) { + if (typeof maxHealth !== "number" || maxHealth < 0) + throw new Error( + `HealthComponent: maxHealth must be a non-negative number, got ${maxHealth}`, + ); + this._max = maxHealth; + this._current = maxHealth; + } + + get current() { + return this._current; + } + get max() { + return this._max; + } + + /** HP as a percentage 0–100 (used by the UI to set health bar widths). */ + get percentage() { + if (this._max === 0) return 0; + return Math.round((this._current / this._max) * 100); + } + + /** @param {number} amount */ + takeDamage(amount) { + this._current = Math.max(0, this._current - amount); + } + + /** @param {number} amount */ + heal(amount) { + this._current = Math.min(this._max, this._current + amount); + } + + isAlive() { + return this._current > 0; + } + + reset() { + this._current = this._max; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/monster.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/monster.js new file mode 100644 index 00000000..4da3e5d2 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/core/monster.js @@ -0,0 +1,203 @@ +/** + * monster.js — base class every monster extends. + * + * ## Architecture + * Monster uses two OOP patterns working together: + * + * - **Inheritance** — your monster class extends Monster and can override + * `onTakeDamage()`, `reset()`, and `imagePath`. + * - **Composition** — HP is delegated to a HealthComponent (`this.hp`). + * Monster never touches HP directly; it calls `this.hp.heal()`, + * `this.hp.takeDamage()`, etc. + * + * ## Stat budget + * Every monster must satisfy: + * + * health + attackPower × 3 ≤ 300 + * + * This keeps fights balanced regardless of stat distribution. + * A monster can be tanky (high HP, low attack) or a glass cannon + * (low HP, high attack) — the formula normalises both. + * + * ## Ability system + * Pass a DamageAbility, HealAbility, or ArmorAbility to the constructor. + * The engine calls `ability.tryActivate(this, opponent)` after every attack. + * You do not need to call it yourself. + * + * ## Lifecycle + * Each tournament bout resets all monsters to full HP via `reset()`. + * If you store extra state (counters, swapped abilities), override `reset()` + * and call `super.reset()` first. + * + * ## Quick-start example + * ```js + * import { Monster } from '../core/monster.js'; + * import { DamageAbility } from '../core/ability.js'; + * + * class FireSurge extends DamageAbility { + * activate(attacker, opponent) { return 30; } + * describe(attacker, amount) { return `${attacker.name} surges for ${amount}!`; } + * } + * + * export class Phoenix extends Monster { + * constructor() { + * // STAT BUDGET: 150 + 50 × 3 = 300 ✓ (max 300) + * super('Phoenix', 150, 50, new FireSurge(30)); + * } + * } + * ``` + */ + +import { HealthComponent } from "./health.js"; +import { Ability } from "./ability.js"; + +export class Monster { + static _nextId = 0; + /** + * @param {string} name Display name shown in the arena UI. + * @param {number} health Starting HP. Must be ≥ 10. + * @param {number} attackPower Base damage per normal attack. Must be ≥ 1. + * @param {Ability|null} ability + * One of DamageAbility, HealAbility, or ArmorAbility — or null for no ability. + * + * @throws If any stat is out of range or the budget is exceeded. + * + * Stat budget: health + attackPower × 3 ≤ 300 + */ + constructor(name, health, attackPower, ability = null) { + if (attackPower < 1) + throw new Error(`[Monster] "${name}": attackPower must be ≥ 1`); + if (health < 10) + throw new Error(`[Monster] "${name}": health must be ≥ 10`); + + const score = health + attackPower * 3; + if (score > 300) { + throw new Error( + `[Monster] "${name}" exceeds the stat budget!\n` + + ` Score: ${health} HP + ${attackPower} attack × 3 = ${score} (max 300)\n` + + ` Reduce your stats by ${score - 300} point(s).\n` + + ` Tip: each attack point costs 3 budget points; each HP costs 1.`, + ); + } + + if (ability !== null && !(ability instanceof Ability)) + throw new Error( + `[Monster] "${name}": ability must be a DamageAbility, HealAbility, or ArmorAbility instance`, + ); + + this.id = `monster-${Monster._nextId++}`; + this.name = name; + this.attackPower = attackPower; + this._initialAttackPower = attackPower; + this.ability = ability; + this._initialAbility = ability; + + // COMPOSITION — HP is managed by HealthComponent, not by Monster directly. + this.hp = new HealthComponent(health); + } + + /** Current HP. Shorthand for `this.hp.current`. */ + get health() { + return this.hp.current; + } + + /** Returns true while HP > 0. */ + isAlive() { + return this.hp.isAlive(); + } + + takeDamage(amount) { + this.hp.takeDamage(amount); + this.onTakeDamage(amount); + } + + /** + * Hook called every time this monster receives damage. + * + * Override to react to damage: track hit counters, swap abilities, + * trigger rage modes, etc. Do **not** modify `attackPower` here — + * use the `activate()` return value or swap the ability instead. + * + * The ability can read any state you set here via the `attacker` + * parameter in `activate(attacker, opponent)`. + * + * If you swap `this.ability` inside this hook, also override `reset()` + * to reset the swapped ability's charges. + * + * @param {number} amount Damage just received (after variance, before HP clamp). + * + * @example + * // Track how many times this monster has been hit + * onTakeDamage(amount) { + * this.hitsTaken++; + * if (this.hitsTaken >= 5) this.ability = this._rageAbility; + * } + */ + // eslint-disable-next-line no-unused-vars + onTakeDamage(amount) {} + + /** + * Deals `attackPower` damage to `opponent` (±15% variance, minimum 1), + * then gives the ability a chance to trigger. + * + * You do not need to call or override this method. The arena engine + * calls it automatically each turn. + * + * @param {Monster} opponent + * @returns {{ damage: number, special: string|null }} + * `damage` — actual HP removed from the opponent this turn. + * `special` — the ability's description string, or null if it did not fire. + */ + attack(opponent) { + const actualDamage = Math.max( + 1, + Math.round(this.attackPower * (0.85 + Math.random() * 0.3)), + ); + opponent.takeDamage(actualDamage); + const usedSpecial = this.ability + ? this.ability.tryActivate(this, opponent) + : null; + return { damage: actualDamage, special: usedSpecial }; + } + + /** + * Restores this monster to its initial state for the next bout. + * + * The arena engine calls this automatically between bouts — you do not + * need to call it yourself. + * + * Override if you store extra state (counters, swapped abilities, etc.). + * Always call `super.reset()` first, then reset your own fields. + * + * Base reset restores: HP to max, `attackPower` to initial value, + * `this.ability` to the original instance, and its charge count. + * + * @example + * reset() { + * super.reset(); // restores HP, attackPower, ability + charges + * this._rageAbility.reset(); // reset any swapped abilities too + * this.hitsTaken = 0; // reset your own counters + * } + */ + reset() { + this.hp.reset(); + this.attackPower = this._initialAttackPower; + this.ability = this._initialAbility; + if (this.ability) this.ability.reset(); + } + + /** + * Path to the monster's image displayed in the arena UI. + * + * Defaults to `assets/monsters/.png`. + * Override to use a different path or file format (e.g. SVG). + * + * @example + * get imagePath() { + * return 'assets/monsters/MyMonster.svg'; + * } + */ + get imagePath() { + return `assets/monsters/${this.constructor.name}.png`; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/main.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/main.js new file mode 100644 index 00000000..8c9e342d --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/main.js @@ -0,0 +1,40 @@ +// index.js +// +// Entry point — import all monster classes and kick off the tournament. +// To add your monster: import it here and add an instance to the array below. +// Pattern: import { ClassName } from './monsters/ClassName.js'; + +import { Dragon } from "./monsters/Dragon.js"; +import { Goblin } from "./monsters/Goblin.js"; +import { Troll } from "./monsters/Troll.js"; + +import { tournament, cancelTournament, monteCarlo } from "./arena.js"; +import "./ui.js"; // registers all DOM event listeners +import "./style.css"; + +// Add a new instance here for each monster you want in the tournament. +const monsters = [ + new Dragon(), + new Goblin(), + new Troll(), + // new YourMonster(), +]; + +// Populate the start screen roster, then run Monte Carlo in the background. +document.dispatchEvent( + new CustomEvent("arena:roster", { + detail: monsters.map((m) => ({ id: m.id, name: m.name })), + }), +); + +setTimeout(() => { + const results = monteCarlo(monsters, 1000); + document.dispatchEvent( + new CustomEvent("arena:montecarlo", { detail: results }), + ); +}, 0); + +window.startTournament = () => { + cancelTournament(); + tournament(monsters); +}; diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Dragon.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Dragon.js new file mode 100644 index 00000000..aa1c6088 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Dragon.js @@ -0,0 +1,55 @@ +// monsters/dragon.js +// +// ── REFERENCE EXAMPLE — read this, then write your own in monsters/your-monster.js ── +// +// What to notice: +// 1. Two classes in one file: an Ability subclass + a Monster subclass +// 2. DragonBreath extends DamageAbility (INHERITANCE on the ability side) +// 3. Dragon extends Monster (INHERITANCE on the monster side) +// 4. Dragon HAS-A DragonBreath (COMPOSITION) +// 5. DragonBreath is injected, not created inside Monster (DEPENDENCY INJECTION) +// 6. activate() returns more when enraged — triggerChance drops automatically +// 7. onTakeDamage() tracks Monster state; activate() reads it via `attacker` + +import { Monster } from "../core/monster.js"; +import { DamageAbility } from "../core/ability.js"; + +class DragonBreath extends DamageAbility { + activate(attacker, opponent) { + return attacker.hitsTaken >= 3 + ? Math.ceil(this.amount * 1.5) // enraged: bigger hit, rarer trigger + : this.amount; + } + + describe(attacker, amount) { + const tag = attacker.hitsTaken >= 3 ? " 💢 ENRAGED" : ""; + const note = + this.chargesMax !== Infinity ? ` (${this.chargesLeft} charges left)` : ""; + return `🔥 ${attacker.name}${tag} breathes fire for ${amount} bonus damage${note}!`; + } +} + +export class Dragon extends Monster { + constructor() { + // DragonBreath(35, 3): base amount 35, 3 charges → chargeMultiplier = 5/3 ≈ 1.67 + // Normal triggerChance = (15 × 1.67) / 35 = 71% + // Enraged triggerChance = (15 × 1.67) / 53 = 47% (Math.ceil(35 × 1.5) = 53) + // + // STAT BUDGET: 160 + 30 × 3 = 250 ✓ (max 300) + super("Dragon", 160, 30, new DragonBreath(35, 3)); + this.hitsTaken = 0; + } + + onTakeDamage(amount) { + this.hitsTaken++; + } + + reset() { + super.reset(); + this.hitsTaken = 0; + } + + get imagePath() { + return "assets/monsters/Dragon.svg"; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Goblin.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Goblin.js new file mode 100644 index 00000000..ad43d330 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Goblin.js @@ -0,0 +1,45 @@ +// monsters/Goblin.js — example monster +// +// The Goblin is a glass cannon: low HP, high attack. +// Its ability tracks hits received and grows more dangerous over time. +// After 4 hits it swaps to a debuff ability to cripple the opponent's attack. + +import { Monster } from "../core/monster.js"; +import { DamageAbility, ArmorAbility } from "../core/ability.js"; + +class GoblinStab extends DamageAbility { + activate(attacker, _opponent) { + // each hit received adds 4 to the next stab — trigger chance drops accordingly + return this.amount + attacker.hitsTaken * 4; + } + + describe(attacker, amount) { + return `${attacker.name} stabs for ${amount} bonus damage! (${attacker.hitsTaken} hits taken)`; + } +} + +export class Goblin extends Monster { + constructor() { + // STAT BUDGET: 70 + 70 × 3 = 280 ✓ (max 300) + super("Goblin", 70, 70, new GoblinStab(10)); + this.hitsTaken = 0; + this._debuff = new ArmorAbility(12, 2); // 2-charge debuff, unlocked after 4 hits + } + + onTakeDamage(_amount) { + this.hitsTaken++; + if (this.hitsTaken === 4) { + this.ability = this._debuff; + } + } + + reset() { + super.reset(); + this.hitsTaken = 0; + this._debuff.reset(); + } + + get imagePath() { + return "assets/monsters/Goblin.svg"; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Troll.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Troll.js new file mode 100644 index 00000000..83465c5c --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/Troll.js @@ -0,0 +1,32 @@ +// monsters/Troll.js — example monster +// +// The Troll regenerates steadily but hits weakly. +// When cornered (HP < 40), it panics and switches to a big heal. + +import { Monster } from "../core/monster.js"; +import { HealAbility } from "../core/ability.js"; + +class TrollRegen extends HealAbility { + activate(attacker, _opponent) { + // panic heal when low — higher amount means rarer trigger (24% vs 80%) + return attacker.hp.current < 65 ? 50 : 15; + } + + describe(attacker, amount) { + const panic = attacker.hp.current < 65; + return panic + ? `${attacker.name} desperately regenerates ${amount} HP!` + : `${attacker.name} slowly regenerates ${amount} HP.`; + } +} + +export class Troll extends Monster { + constructor() { + // STAT BUDGET: 210 + 30 × 3 = 300 ✓ (max 300) + super("Troll", 210, 30, new TrollRegen(15)); + } + + get imagePath() { + return "assets/monsters/Troll.svg"; + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/your-monster.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/your-monster.js new file mode 100644 index 00000000..497292c8 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/monsters/your-monster.js @@ -0,0 +1,45 @@ +// monsters/your-monster.js +// see dragon.js for a reference example + +import { Monster } from "../core/monster.js"; +import { DamageAbility, HealAbility, ArmorAbility } from "../core/ability.js"; + +// ── Step 1: write your ability ──────────────────────────────────────────────── +// Extend one of the three base types. Override activate() and describe(). +// +// triggerChance = budget / amount (higher amount = rarer trigger) +// DamageAbility budget: 15 HealAbility budget: 12 ArmorAbility budget: 8 + +class MyAbility extends DamageAbility { + // swap DamageAbility → HealAbility or ArmorAbility if you want a different effect + activate(attacker, opponent) { + return this.amount; + } + + describe(attacker, amount) { + return `${attacker.name} hits for ${amount} bonus damage!`; + } +} + +// ── Step 2: write your monster ──────────────────────────────────────────────── + +export class YourMonster extends Monster { + // rename class + file to your monster's name (case-sensitive!) + constructor() { + // STAT BUDGET: health + attackPower * 3 must be ≤ 300 + super("YourMonster", 100, 15, new MyAbility(20)); + // pre-create extra abilities here if you want to swap in onTakeDamage: + // this._secondAbility = new HealAbility(15); + } + + // onTakeDamage(amount) { + // this.hitsTaken++; // track state, read in activate() + // // this.ability = this._secondAbility; // swap ability + // } + + // reset() { + // super.reset(); // restores original ability + charges + // this._secondAbility.reset(); // reset swapped ability charges too + // this.hitsTaken = 0; + // } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/style.css b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/style.css new file mode 100644 index 00000000..41f7f342 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/style.css @@ -0,0 +1,718 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #10101f; + --surface: #1a1a30; + --accent: #ff00ff; + --cyan: #00ffff; + --neon-left: #00ffff; + --neon-right: #ff00ff; + --green: #39ff14; + --yellow: #ffd700; + --red: #ff2020; + --text: #e8e8f0; + --muted: #8899aa; + --radius: 4px; + --font-pixel: "Press Start 2P", monospace; + --font-mono: "Courier New", Courier, monospace; + --glow-cyan: 0 0 12px #00ffff, 0 0 30px rgba(0, 255, 255, 0.4); + --glow-accent: 0 0 12px #ff00ff, 0 0 30px rgba(255, 0, 255, 0.4); +} + +[hidden] { + display: none !important; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); + min-height: 100vh; + display: flex; + flex-direction: column; + position: relative; +} + +header { + text-align: center; + padding: 1.5rem 1rem 0.5rem; + border-bottom: 1px solid rgba(0, 255, 255, 0.15); +} +header h1 { + font-family: var(--font-pixel); + font-size: 1.6rem; + color: var(--cyan); + letter-spacing: 3px; + text-shadow: + 0 0 8px var(--cyan), + 0 0 20px rgba(0, 255, 255, 0.6), + 0 0 40px rgba(0, 255, 255, 0.3); +} +#bout-label { + font-family: var(--font-pixel); + font-size: 0.65rem; + color: var(--accent); + margin-top: 0.5rem; + letter-spacing: 1px; + text-shadow: 0 0 6px var(--accent); +} + +/* ── Arena layout ── */ +.arena { + display: grid; + grid-template-columns: 1fr 320px 1fr; + gap: 1rem; + flex: 1; + padding: 1.5rem; + align-items: center; + position: relative; +} + +/* ── VS overlay ── */ +#vs-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 50; + opacity: 0; + background: rgba(0, 0, 0, 0.65); +} +#vs-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + text-align: center; +} +#vs-name-left { + font-family: var(--font-pixel); + font-size: 1.6rem; + color: var(--neon-left); + letter-spacing: 0.1em; + text-shadow: + 0 0 12px var(--neon-left), + 0 0 30px rgba(0, 255, 255, 0.5); +} +#vs-text { + font-family: var(--font-pixel); + font-size: 3rem; + color: var(--cyan); + letter-spacing: 0.2em; + text-shadow: + 0 0 20px var(--cyan), + 0 0 60px rgba(0, 255, 255, 0.6), + 4px 4px 0 var(--accent); +} +#vs-name-right { + font-family: var(--font-pixel); + font-size: 1.6rem; + color: var(--neon-right); + letter-spacing: 0.1em; + text-shadow: + 0 0 12px var(--neon-right), + 0 0 30px rgba(255, 0, 255, 0.5); +} + +/* ── Fighter cards ── */ +.fighter { + background: var(--surface); + border-radius: var(--radius); + padding: 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + transition: box-shadow 0.4s ease; + position: relative; + overflow: visible; + border: 2px solid transparent; +} +#fighter-left { + border-color: var(--neon-left); + box-shadow: 0 0 10px rgba(0, 255, 255, 0.2); +} +#fighter-right { + border-color: var(--neon-right); + box-shadow: 0 0 10px rgba(255, 0, 255, 0.2); +} + +.monster-img-wrap { + width: 180px; + height: 180px; + border-radius: var(--radius); + overflow: hidden; + background: #060610; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.06); + image-rendering: pixelated; +} +.monster-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.monster-name { + font-family: var(--font-pixel); + font-size: 0.8rem; + font-weight: 400; + letter-spacing: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.health-bar-wrap { + width: 100%; + height: 16px; + background: #111; + border-radius: 2px; + overflow: hidden; + border: 1px solid #333; +} +.health-bar { + height: 100%; + width: 100%; + background: var(--green); + border-radius: 0; + transform-origin: left center; + /* GSAP controls width — do not add transform to transition */ + transition: background 0.3s; +} +.hp-label { + font-family: var(--font-pixel); + font-size: 0.6rem; + color: var(--muted); + letter-spacing: 1px; +} + +@keyframes low-hp-pulse { + 0%, + 100% { + box-shadow: 0 0 6px rgba(255, 32, 32, 0.4); + } + 50% { + box-shadow: + 0 0 20px rgba(255, 32, 32, 0.9), + 0 0 40px rgba(255, 32, 32, 0.4); + } +} +.fighter.low-hp { + animation: low-hp-pulse 0.8s ease-in-out infinite; +} + +/* ── Battle log ── */ +.log-wrap { + background: var(--surface); + border-radius: var(--radius); + padding: 1rem; + height: 650px; + overflow-y: auto; + scroll-behavior: smooth; + border: 1px solid rgba(0, 255, 255, 0.1); +} +.log { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.log li { + font-size: 0.85rem; + padding: 0.3rem 0.5rem; + border-radius: 3px; + animation: fadein 0.3s ease; + border-left: 2px solid transparent; +} +.log li.attack { + background: rgba(0, 255, 255, 0.04); + border-left-color: var(--cyan); +} +.log li.special { + background: rgba(255, 0, 255, 0.08); + border-left-color: var(--accent); + color: #ff88ff; +} +.log li.bout { + background: rgba(57, 255, 20, 0.07); + border-left-color: var(--green); + color: #a0ff80; +} +.log li.divider { + color: var(--muted); + text-align: center; + font-size: 0.72rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 0.5rem; + border-left: none; +} + +@keyframes fadein { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +/* ── CRT scanlines ── */ +body::after { + content: ""; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 2px, + rgba(0, 0, 0, 0.1) 2px, + rgba(0, 0, 0, 0.1) 4px + ); + pointer-events: none; + z-index: 9999; +} + +/* ── Leaderboard overlay ── */ +.leaderboard-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 10, 0.92); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + z-index: 300; +} +.leaderboard-overlay.visible { + opacity: 1; + pointer-events: auto; +} +.leaderboard-card { + background: var(--surface); + border: 2px solid var(--cyan); + border-radius: var(--radius); + padding: 2.5rem 3rem; + min-width: 360px; + text-align: center; + box-shadow: var(--glow-cyan); +} +.leaderboard-card h2 { + font-family: var(--font-pixel); + font-size: 1rem; + color: var(--cyan); + margin-bottom: 1.5rem; + letter-spacing: 2px; + text-shadow: 0 0 10px var(--cyan); +} +#leaderboard-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +#leaderboard-list li { + display: flex; + justify-content: space-between; + font-family: var(--font-pixel); + font-size: 0.65rem; + letter-spacing: 1px; + padding: 0.5rem 0.75rem; + background: rgba(0, 255, 255, 0.05); + border: 1px solid rgba(0, 255, 255, 0.15); + border-radius: 2px; +} +#leaderboard-list li:first-child { + color: gold; +} +#leaderboard-list li:nth-child(2) { + color: silver; +} +#leaderboard-list li:nth-child(3) { + color: #cd7f32; +} + +#header-restart-btn { + font-family: var(--font-pixel); + font-size: 0.5rem; + color: var(--bg); + background: var(--accent); + border: none; + border-radius: var(--radius); + padding: 0.5rem 1rem; + margin-top: 0.75rem; + cursor: pointer; + letter-spacing: 1px; + box-shadow: 0 0 10px rgba(255, 0, 255, 0.4); + transition: + background 0.15s, + box-shadow 0.15s; +} +#header-restart-btn:hover { + background: #fff; + box-shadow: 0 0 18px rgba(255, 0, 255, 0.8); +} + +#restart-btn { + font-family: var(--font-pixel); + font-size: 0.6rem; + color: var(--bg); + background: var(--cyan); + border: none; + border-radius: var(--radius); + padding: 0.75rem 1.5rem; + margin-top: 1.5rem; + cursor: pointer; + letter-spacing: 1px; + box-shadow: 0 0 12px rgba(0, 255, 255, 0.5); + transition: + background 0.15s, + box-shadow 0.15s; +} +#restart-btn:hover { + background: #fff; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.8); +} + +/* ── Floating damage numbers ── */ +.damage-number { + position: absolute; + font-family: var(--font-pixel); + font-size: 1.2rem; + pointer-events: none; + z-index: 60; + text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.8); +} +.damage-number.normal { + color: #fff; +} +.damage-number.special { + color: var(--accent); + font-size: 1.5rem; +} +.damage-number.crit { + color: var(--yellow); + font-size: 1.8rem; +} + +/* ── Start screen ── */ +#start-screen { + position: fixed; + inset: 0; + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +#start-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + text-align: center; +} +#start-subtitle { + font-family: var(--font-pixel); + font-size: 0.55rem; + color: var(--muted); + letter-spacing: 3px; + text-transform: uppercase; +} +#start-title { + font-family: var(--font-pixel); + font-size: 2.8rem; + line-height: 1.4; + color: var(--cyan); + text-shadow: + 0 0 12px var(--cyan), + 0 0 40px rgba(0, 255, 255, 0.5), + 4px 4px 0 var(--accent); +} +#start-roster { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +#start-roster li { + font-family: var(--font-pixel); + font-size: 0.55rem; + color: var(--text); + letter-spacing: 1px; + padding: 0.4rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.03); +} +#start-roster li::before { + content: "▶ "; + color: var(--accent); +} + +#montecarlo-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(0, 255, 255, 0.15); +} + +#montecarlo-label { + font-family: var(--font-pixel); + font-size: 0.45rem; + color: var(--muted); + letter-spacing: 2px; + text-transform: uppercase; + text-align: center; + margin-bottom: 0.25rem; +} + +#montecarlo-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +#montecarlo-list li { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mc-name-row { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.mc-name { + font-family: var(--font-pixel); + font-size: 0.5rem; + color: var(--text); + letter-spacing: 1px; +} + +.mc-pct { + font-family: var(--font-pixel); + font-size: 0.5rem; + color: var(--cyan); +} + +.mc-bar-track { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; +} + +.mc-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--cyan); + transition: width 0.6s ease; +} + +.mc-bar-fill.rank-1 { + background: gold; +} +.mc-bar-fill.rank-2 { + background: silver; +} +.mc-bar-fill.rank-3 { + background: #cd7f32; +} +#start-btn { + font-family: var(--font-pixel); + font-size: 0.65rem; + color: var(--bg); + background: var(--cyan); + border: none; + border-radius: var(--radius); + padding: 0.9rem 2rem; + cursor: pointer; + letter-spacing: 2px; + box-shadow: 0 0 14px rgba(0, 255, 255, 0.5); + animation: press-start-blink 1.2s step-end infinite; +} +#start-btn:hover { + background: #fff; + box-shadow: 0 0 24px rgba(0, 255, 255, 0.9); + animation: none; +} +@keyframes press-start-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* ── Bout result overlays ── */ +.bout-result-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: var(--font-pixel); + font-size: 0.9rem; + letter-spacing: 3px; + pointer-events: none; + z-index: 10; + text-align: center; + white-space: nowrap; +} +.bout-result-badge.winner { + color: #fff; + text-shadow: + 0 0 10px currentColor, + 0 0 30px currentColor, + 2px 2px 0 rgba(0, 0, 0, 0.8); +} +#fighter-left .bout-result-badge.winner { + color: var(--neon-left); +} +#fighter-right .bout-result-badge.winner { + color: var(--neon-right); +} +.fighter.bout-loser { + opacity: 0.35; + filter: grayscale(80%); + transition: + opacity 0.5s ease, + filter 0.5s ease; +} + +/* ── Round Robin Matrix ── */ +#round-robin { + padding: 1.5rem; + border-top: 1px solid rgba(0, 255, 255, 0.1); +} +#rr-title { + font-family: var(--font-pixel); + font-size: 0.55rem; + color: var(--muted); + letter-spacing: 3px; + text-transform: uppercase; + text-align: center; + margin-bottom: 1rem; +} +#rr-table-wrap { + overflow-x: auto; + display: flex; + justify-content: center; +} +#rr-table { + border-collapse: separate; + border-spacing: 4px; +} +#rr-table th { + font-family: var(--font-pixel); + font-size: 0.45rem; + color: var(--muted); + letter-spacing: 1px; + padding: 0.4rem 0.6rem; + text-align: center; + white-space: nowrap; +} +#rr-table th.rr-active { + color: var(--cyan); +} +#rr-table td { + width: 54px; + height: 40px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 3px; + text-align: center; + vertical-align: middle; + font-family: var(--font-pixel); + font-size: 0.5rem; + letter-spacing: 1px; + transition: + background 0.3s, + color 0.3s, + box-shadow 0.3s; +} +#rr-table td.rr-self { + background: rgba(255, 255, 255, 0.02); + color: rgba(255, 255, 255, 0.15); +} +#rr-table td.rr-win { + background: rgba(57, 255, 20, 0.12); + color: var(--green); + border-color: rgba(57, 255, 20, 0.3); +} +#rr-table td.rr-loss { + background: rgba(255, 32, 32, 0.1); + color: var(--red); + border-color: rgba(255, 32, 32, 0.3); +} +#rr-table td.rr-draw { + background: rgba(255, 215, 0, 0.1); + color: var(--yellow); + border-color: rgba(255, 215, 0, 0.3); +} +@keyframes rr-active-pulse { + 0%, + 100% { + box-shadow: 0 0 6px rgba(0, 255, 255, 0.3); + border-color: rgba(0, 255, 255, 0.4); + } + 50% { + box-shadow: 0 0 14px rgba(0, 255, 255, 0.8); + border-color: rgba(0, 255, 255, 0.9); + } +} +#rr-table td.rr-active { + animation: rr-active-pulse 0.9s ease-in-out infinite; +} + +/* ── Responsive ── */ +@media (max-width: 860px) { + .arena { + grid-template-columns: 1fr; + } + .log-wrap { + height: 400px; + } +} + +@media (prefers-reduced-motion: reduce) { + .log li { + animation: none; + } + body::after { + display: none; + } + .fighter.low-hp { + animation: none; + } + #start-btn { + animation: none; + } + #rr-table td.rr-active { + animation: none; + border-color: var(--cyan); + } +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/ui.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/ui.js new file mode 100644 index 00000000..094cce07 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/src/ui.js @@ -0,0 +1,538 @@ +// ui.js — arena UI. Students do not edit this file. + +import gsap from "gsap"; + +const boutLabel = document.getElementById("bout-label"); +const imgLeft = document.getElementById("img-left"); +const imgRight = document.getElementById("img-right"); +const nameLeft = document.getElementById("name-left"); +const nameRight = document.getElementById("name-right"); +const barLeft = document.getElementById("bar-left"); +const barRight = document.getElementById("bar-right"); +const hpLeft = document.getElementById("hp-left"); +const hpRight = document.getElementById("hp-right"); +const log = document.getElementById("log"); +const leaderboard = document.getElementById("leaderboard"); +const lbList = document.getElementById("leaderboard-list"); +const fighterLeft = document.getElementById("fighter-left"); +const fighterRight = document.getElementById("fighter-right"); +const closeBtn = document.getElementById("leaderboard-close"); +const headerRestartBtn = document.getElementById("header-restart-btn"); +const startScreen = document.getElementById("start-screen"); +const startRoster = document.getElementById("start-roster"); +const rrTable = document.getElementById("rr-table"); + +const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", +).matches; +const prevHp = { left: null, right: null }; + +let _rrMonsters = []; // [{ id, name }] +let _rrActivePair = null; + +function buildRRTable() { + rrTable.innerHTML = ""; + const thead = rrTable.createTHead(); + const headRow = thead.insertRow(); + headRow.insertCell(); + _rrMonsters.forEach(({ id, name }) => { + const th = document.createElement("th"); + th.textContent = name; + th.dataset.colId = id; + headRow.appendChild(th); + }); + const tbody = rrTable.createTBody(); + _rrMonsters.forEach(({ id: rowId, name: rowName }) => { + const tr = tbody.insertRow(); + const th = document.createElement("th"); + th.textContent = rowName; + th.dataset.rowId = rowId; + tr.appendChild(th); + _rrMonsters.forEach(({ id: colId }) => { + const td = tr.insertCell(); + td.dataset.rowId = rowId; + td.dataset.colId = colId; + if (rowId === colId) { + td.className = "rr-self"; + td.textContent = "—"; + } + }); + }); +} + +function updateRRCell(rowId, colId, state) { + const td = rrTable.querySelector( + `td[data-row-id="${rowId}"][data-col-id="${colId}"]`, + ); + if (!td || td.classList.contains("rr-self")) return; + td.className = `rr-${state}`; + td.textContent = + state === "active" + ? "···" + : state === "win" + ? "W" + : state === "loss" + ? "L" + : "D"; +} + +function setRRHeaderActive(aId, bId, active) { + rrTable.querySelectorAll("th").forEach((th) => { + if ( + th.dataset.rowId === aId || + th.dataset.rowId === bId || + th.dataset.colId === aId || + th.dataset.colId === bId + ) { + th.classList.toggle("rr-active", active); + } + }); +} + +function barColor(pct) { + if (pct > 60) return "#39ff14"; + if (pct > 30) return "#ffd700"; + return "#ff2020"; +} + +function updateBar(side, hp, maxHp, pct) { + const bar = side === "left" ? barLeft : barRight; + const hpEl = side === "left" ? hpLeft : hpRight; + const card = side === "left" ? fighterLeft : fighterRight; + // Kill in-flight tween so back-to-back updates start cleanly. + gsap.killTweensOf(bar, "width"); + gsap.to(bar, { + width: `${pct}%`, + backgroundColor: barColor(pct), + duration: 0.4, + ease: "power2.out", + }); + hpEl.textContent = `${hp} / ${maxHp}`; + card.classList.toggle("low-hp", pct <= 30); +} + +function addLog(text, cls) { + const li = document.createElement("li"); + li.className = cls; + li.textContent = text; + log.appendChild(li); + log.parentElement.scrollTop = log.parentElement.scrollHeight; +} + +function hitFlash(side) { + if (prefersReducedMotion) return; + const card = side === "left" ? fighterLeft : fighterRight; + gsap + .timeline() + .to(card, { + backgroundColor: "rgba(255,255,255,0.35)", + duration: 0.06, + ease: "none", + }) + .to(card, { + backgroundColor: "", + duration: 0.12, + ease: "power2.out", + clearProps: "backgroundColor", + }); +} + +function spawnDamageNumber(side, damage, isSpecial) { + if (prefersReducedMotion || !damage) return; + const card = side === "left" ? fighterLeft : fighterRight; + const el = document.createElement("div"); + el.className = `damage-number ${isSpecial ? "special" : damage > 30 ? "crit" : "normal"}`; + el.textContent = `-${damage}`; + el.setAttribute("aria-hidden", "true"); + const w = card.offsetWidth; + el.style.left = `${Math.random() * w * 0.5 + w * 0.25}px`; + el.style.top = "80px"; + card.appendChild(el); + gsap.fromTo( + el, + { opacity: 1, y: 0, scale: 1.2 }, + { + opacity: 0, + y: -55, + scale: 0.8, + duration: 0.85, + ease: "power1.out", + onComplete: () => el.remove(), + }, + ); +} + +function shakeCard(side) { + const card = side === "left" ? fighterLeft : fighterRight; + gsap.killTweensOf(card, "x"); + gsap.set(card, { x: 0 }); + gsap.fromTo( + card, + { x: -6 }, + { + x: 6, + duration: 0.08, + repeat: 3, + yoyo: true, + ease: "none", + onComplete: () => gsap.set(card, { x: 0 }), + }, + ); +} + +function glowCard(side) { + const card = side === "left" ? fighterLeft : fighterRight; + const color = side === "left" ? "0,255,255" : "255,0,255"; + gsap.killTweensOf(card, "boxShadow"); + gsap.fromTo( + card, + { boxShadow: `0 0 40px rgba(${color},1), 0 0 80px rgba(${color},0.6)` }, + { + boxShadow: `0 0 10px rgba(${color},0.2)`, + duration: 0.9, + ease: "power2.out", + }, + ); +} + +function vsFlash(aName, bName) { + if (prefersReducedMotion) return; + const overlay = document.getElementById("vs-overlay"); + const vsText = document.getElementById("vs-text"); + const nameLeft = document.getElementById("vs-name-left"); + const nameRight = document.getElementById("vs-name-right"); + nameLeft.textContent = aName; + nameRight.textContent = bName; + gsap.killTweensOf([overlay, vsText, nameLeft, nameRight]); + gsap.set(overlay, { opacity: 0 }); + gsap.set(vsText, { scale: 2.5, opacity: 0 }); + gsap.set(nameLeft, { opacity: 0, y: -10 }); + gsap.set(nameRight, { opacity: 0, y: 10 }); + gsap + .timeline() + .to(overlay, { opacity: 1, duration: 0.15 }) + .to( + vsText, + { scale: 1, opacity: 1, duration: 0.25, ease: "back.out(3)" }, + "<", + ) + .to( + nameLeft, + { opacity: 1, y: 0, duration: 0.2, ease: "power2.out" }, + "-=0.1", + ) + .to(nameRight, { opacity: 1, y: 0, duration: 0.2, ease: "power2.out" }, "<") + .to({}, { duration: 0.5 }) + .to(overlay, { opacity: 0, duration: 0.2, ease: "power2.in" }) + .to(vsText, { opacity: 0, scale: 0.8, duration: 0.2 }, "<"); +} + +function victoryPulse(winnerSide) { + if (!winnerSide) return; + const winCard = winnerSide === "left" ? fighterLeft : fighterRight; + const loseCard = winnerSide === "left" ? fighterRight : fighterLeft; + const color = winnerSide === "left" ? "0,255,255" : "255,0,255"; + + loseCard.classList.add("bout-loser"); + + if (!prefersReducedMotion) { + gsap.killTweensOf(winCard, "scale,boxShadow"); + gsap + .timeline() + .to(winCard, { + scale: 1.06, + boxShadow: `0 0 40px rgba(${color},1)`, + duration: 0.25, + ease: "power2.out", + }) + .to(winCard, { + scale: 1.03, + boxShadow: `0 0 20px rgba(${color},0.6)`, + duration: 0.25, + yoyo: true, + repeat: 3, + }) + .to(winCard, { + scale: 1, + boxShadow: `0 0 10px rgba(${color},0.2)`, + duration: 0.3, + clearProps: "scale", + }); + } + + const badge = document.createElement("div"); + badge.className = "bout-result-badge winner"; + badge.textContent = "WINNER!"; + badge.setAttribute("aria-hidden", "true"); + winCard.appendChild(badge); + if (!prefersReducedMotion) { + gsap.fromTo( + badge, + { opacity: 0, scale: 1.6 }, + { opacity: 1, scale: 1, duration: 0.35, ease: "back.out(2)" }, + ); + } +} + +function shakeArena() { + if (prefersReducedMotion) return; + const arena = document.querySelector(".arena"); + gsap.killTweensOf(arena, "x,y"); + gsap.to(arena, { + keyframes: [ + { x: -5, y: 3, duration: 0.05 }, + { x: 5, y: -4, duration: 0.05 }, + { x: -4, y: 2, duration: 0.05 }, + { x: 3, y: -3, duration: 0.05 }, + { x: -2, y: 1, duration: 0.05 }, + { x: 0, y: 0, duration: 0.05 }, + ], + ease: "none", + onComplete: () => gsap.set(arena, { clearProps: "x,y" }), + }); +} + +document.addEventListener("arena:boutStart", ({ detail: d }) => { + const strikeNote = d.firstAttacker + ? ` — ${d.firstAttacker} strikes first!` + : ""; + boutLabel.textContent = `${d.aName} vs ${d.bName}${strikeNote}`; + + imgLeft.src = d.aImagePath; + imgLeft.alt = d.aName; + imgRight.src = d.bImagePath; + imgRight.alt = d.bName; + nameLeft.textContent = d.aName; + nameRight.textContent = d.bName; + + fighterLeft.classList.remove("low-hp", "bout-loser"); + fighterRight.classList.remove("low-hp", "bout-loser"); + fighterLeft + .querySelectorAll(".bout-result-badge") + .forEach((el) => el.remove()); + fighterRight + .querySelectorAll(".bout-result-badge") + .forEach((el) => el.remove()); + prevHp.left = d.aMaxHp; + prevHp.right = d.bMaxHp; + + gsap.set(barLeft, { width: "100%", backgroundColor: "#39ff14" }); + gsap.set(barRight, { width: "100%", backgroundColor: "#39ff14" }); + hpLeft.textContent = `${d.aMaxHp} / ${d.aMaxHp}`; + hpRight.textContent = `${d.bMaxHp} / ${d.bMaxHp}`; + + addLog(`── ${d.aName} vs ${d.bName} ──`, "divider"); + + vsFlash(d.aName, d.bName); + if (!prefersReducedMotion) { + gsap.fromTo( + fighterLeft, + { x: -40, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.4, ease: "power2.out" }, + ); + gsap.fromTo( + fighterRight, + { x: 40, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.4, ease: "power2.out" }, + ); + } + + if (_rrActivePair) setRRHeaderActive(_rrActivePair.a, _rrActivePair.b, false); + _rrActivePair = { a: d.aId, b: d.bId }; + updateRRCell(d.aId, d.bId, "active"); + updateRRCell(d.bId, d.aId, "active"); + setRRHeaderActive(d.aId, d.bId, true); +}); + +document.addEventListener("arena:attack", ({ detail: d }) => { + const defSide = d.attackerSide === "left" ? "right" : "left"; + const dmg = + prevHp[defSide] !== null ? prevHp[defSide] - d.defenderHp : d.damage; + prevHp[defSide] = d.defenderHp; + shakeCard(d.attackerSide); + hitFlash(defSide); + spawnDamageNumber(defSide, dmg, false); + updateBar(defSide, d.defenderHp, d.defenderMaxHp, d.defenderPct); + addLog( + `${d.attackerName} attacks ${d.defenderName} for ${d.damage} damage`, + "attack", + ); +}); + +document.addEventListener("arena:special", ({ detail: d }) => { + const defSide = d.attackerSide === "left" ? "right" : "left"; + const dmg = prevHp[defSide] !== null ? prevHp[defSide] - d.defenderHp : 0; + prevHp[defSide] = d.defenderHp; + prevHp[d.attackerSide] = d.attackerHp; + glowCard(d.attackerSide); + shakeArena(); + hitFlash(defSide); + spawnDamageNumber(defSide, dmg, true); + updateBar(defSide, d.defenderHp, d.defenderMaxHp, d.defenderPct); + updateBar(d.attackerSide, d.attackerHp, d.attackerMaxHp, d.attackerPct); + addLog(d.description, "special"); +}); + +document.addEventListener("arena:boutEnd", ({ detail: d }) => { + if (d.isDraw) { + addLog("Draw! No points awarded.", "bout"); + } else { + addLog(`${d.winnerName} wins the bout!`, "bout"); + const winnerSide = + _rrActivePair && d.winnerId === _rrActivePair.a + ? "left" + : _rrActivePair && d.winnerId === _rrActivePair.b + ? "right" + : null; + victoryPulse(winnerSide); + } + + if (_rrActivePair) { + const { a, b } = _rrActivePair; + setRRHeaderActive(a, b, false); + if (d.isDraw) { + updateRRCell(a, b, "draw"); + updateRRCell(b, a, "draw"); + } else { + const winnerId = d.winnerId; + const loserId = winnerId === a ? b : a; + updateRRCell(winnerId, loserId, "win"); + updateRRCell(loserId, winnerId, "loss"); + } + _rrActivePair = null; + } +}); + +document.addEventListener("arena:tournamentEnd", ({ detail: d }) => { + if (_rrActivePair) { + setRRHeaderActive(_rrActivePair.a, _rrActivePair.b, false); + _rrActivePair = null; + } + + lbList.innerHTML = ""; + d.leaderboard.forEach(({ name, wins }, i) => { + const li = document.createElement("li"); + const medal = ["1ST", "2ND", "3RD"][i] ?? `${i + 1}.`; + li.innerHTML = `${medal} ${name}${wins} win${wins !== 1 ? "s" : ""}`; + lbList.appendChild(li); + }); + + populateMonteCarlo(); + + leaderboard.classList.add("visible"); + leaderboard.setAttribute("aria-hidden", "false"); + gsap.fromTo( + leaderboard, + { opacity: 0 }, + { opacity: 1, duration: 0.6, ease: "power2.out" }, + ); + gsap.fromTo( + ".leaderboard-card", + { y: 40, opacity: 0 }, + { y: 0, opacity: 1, duration: 0.6, delay: 0.2, ease: "back.out(1.4)" }, + ); + headerRestartBtn.hidden = false; +}); + +function closeLeaderboard() { + gsap.to(leaderboard, { + opacity: 0, + duration: 0.3, + onComplete: () => { + leaderboard.classList.remove("visible"); + leaderboard.setAttribute("aria-hidden", "true"); + gsap.set(leaderboard, { clearProps: "opacity" }); + }, + }); +} + +closeBtn.addEventListener("click", closeLeaderboard); + +function resetUI() { + log.innerHTML = ""; + boutLabel.textContent = "Waiting for the tournament to start..."; + fighterLeft.classList.remove("low-hp", "bout-loser"); + fighterRight.classList.remove("low-hp", "bout-loser"); + fighterLeft + .querySelectorAll(".bout-result-badge") + .forEach((el) => el.remove()); + fighterRight + .querySelectorAll(".bout-result-badge") + .forEach((el) => el.remove()); + prevHp.left = null; + prevHp.right = null; + headerRestartBtn.hidden = true; + _rrMonsters = []; + _rrActivePair = null; + // Table rebuilds on next arena:roster event +} + +function doRestart() { + closeLeaderboard(); + resetUI(); + window.startTournament(); +} + +document.getElementById("restart-btn").addEventListener("click", doRestart); +headerRestartBtn.addEventListener("click", doRestart); + +// ── Start screen ── +document.getElementById("start-btn").addEventListener("click", () => { + if (prefersReducedMotion) { + startScreen.hidden = true; + } else { + gsap.to(startScreen, { + opacity: 0, + duration: 0.4, + ease: "power2.in", + onComplete: () => { + startScreen.hidden = true; + }, + }); + } + window.startTournament(); +}); + +document.addEventListener("arena:roster", ({ detail: monsters }) => { + startRoster.innerHTML = ""; + monsters.forEach(({ name }) => { + const li = document.createElement("li"); + li.textContent = name; + startRoster.appendChild(li); + }); + _rrMonsters = monsters; // [{ id, name }] + _rrActivePair = null; + buildRRTable(); +}); + +let _mcResults = null; + +document.addEventListener("arena:montecarlo", ({ detail: results }) => { + _mcResults = results; +}); + +function populateMonteCarlo() { + if (!_mcResults) return; + const list = document.getElementById("montecarlo-list"); + const label = document.getElementById("montecarlo-label"); + label.textContent = "1 000 sim win rate"; + list.innerHTML = ""; + + _mcResults.forEach(({ name, winRate }, i) => { + const pct = Math.round(winRate * 100); + const li = document.createElement("li"); + li.innerHTML = ` +
    + ${name} + ${pct}% +
    +
    +
    +
    `; + list.appendChild(li); + + requestAnimationFrame(() => { + li.querySelector(".mc-bar-fill").style.width = `${pct}%`; + }); + }); +} diff --git a/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/test.js b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/test.js new file mode 100644 index 00000000..0422f294 --- /dev/null +++ b/courses/frontend/advanced-javascript/week4/session-materials/oop-monster-arena/test.js @@ -0,0 +1,207 @@ +// test.js +// +// Quick sanity-check for your monster class. +// +// Usage: +// npm test ← tests src/monsters/your-monster.js +// npm test src/monsters/Hydra.js ← tests your renamed file + +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Which file to test? ── +const target = process.argv[2] ?? "src/monsters/your-monster.js"; +const filePath = path.resolve(__dirname, target); + +// ── Output helpers ── +const green = (s) => `\x1b[32m${s}\x1b[0m`; +const red = (s) => `\x1b[31m${s}\x1b[0m`; +const yellow = (s) => `\x1b[33m${s}\x1b[0m`; +const bold = (s) => `\x1b[1m${s}\x1b[0m`; + +let passed = 0; +let failed = 0; + +function ok(label) { + console.log(` ${green("✓")} ${label}`); + passed++; +} + +function fail(label, reason) { + console.log(` ${red("✗")} ${label}`); + console.log(` ${red("→")} ${reason}`); + failed++; +} + +// ── Load monster module ── +console.log(`\n${bold("Monster Arena — Test Runner")}`); +console.log(`Testing: ${yellow(target)}\n`); + +let MonsterClass; +try { + const mod = await import(filePath); + const exports = Object.values(mod).filter((v) => typeof v === "function"); + if (exports.length === 0) { + console.log( + red("✗ No exported class found — did you forget `export class ...`?"), + ); + process.exit(1); + } + MonsterClass = exports[0]; + ok(`File loads without syntax errors`); +} catch (err) { + console.log(red(`✗ Failed to load ${target}:`)); + console.log(` ${err.message}`); + process.exit(1); +} + +// ── Instantiation + stat budget ── +let monster; +try { + monster = new MonsterClass(); + ok(`new ${MonsterClass.name}() constructs without error`); + const score = monster.hp.max + monster.attackPower * 3; + const abilityNote = monster.ability + ? ` | ability: ${monster.ability.constructor.name}(${monster.ability.amount}), triggerChance: ${(monster.ability.triggerChance * 100).toFixed(0)}%` + : " | no ability"; + ok( + `Stat budget: ${monster.hp.max} HP + ${monster.attackPower} atk × 3 = ${score}/300${abilityNote}`, + ); +} catch (err) { + fail(`Constructor throws an error`, err.message); + console.log(`\n${red("Fix the error above before testing further.")}`); + process.exit(1); +} + +// ── Basic properties ── +if (typeof monster.name === "string" && monster.name.length > 0) { + ok(`name is a non-empty string: "${monster.name}"`); +} else { + fail( + `name must be a non-empty string`, + `got: ${JSON.stringify(monster.name)}`, + ); +} + +if (typeof monster.attackPower === "number" && monster.attackPower >= 1) { + ok(`attackPower is a number ≥ 1: ${monster.attackPower}`); +} else { + fail(`attackPower must be a number ≥ 1`, `got: ${monster.attackPower}`); +} + +if (monster.isAlive()) { + ok(`isAlive() returns true at full HP`); +} else { + fail( + `isAlive() should return true at start`, + `returned false — health may be 0?`, + ); +} + +// ── attack() shape ── +const dummy = new MonsterClass(); // opponent +let attackResult; +try { + attackResult = monster.attack(dummy); + if (typeof attackResult?.damage === "number" && attackResult.damage >= 1) { + ok( + `attack() returns { damage: ${attackResult.damage}, special: ${JSON.stringify(attackResult.special)} }`, + ); + } else { + fail( + `attack() must return { damage: number, ... }`, + `got: ${JSON.stringify(attackResult)}`, + ); + } +} catch (err) { + fail(`attack() threw an error`, err.message); +} + +// ── ability shape ── +monster.reset(); +dummy.reset(); +if (monster.ability === null) { + ok(`ability is null (no ability injected)`); +} else { + const { Ability } = await import( + path.resolve(__dirname, "src/core/ability.js") + ); + if (monster.ability instanceof Ability) { + const chance = (monster.ability.triggerChance * 100).toFixed(0); + ok( + `ability is a ${monster.ability.constructor.name} instance (triggerChance: ${chance}%)`, + ); + } else { + fail( + `ability must be a DamageAbility, HealAbility, or ArmorAbility`, + `got: ${monster.ability}`, + ); + } + try { + const result = monster.ability.tryActivate(monster, dummy); + if (result === null || typeof result === "string") { + ok( + `ability.tryActivate() returns ${result === null ? "null (did not trigger this roll)" : `a string`}`, + ); + } else { + fail( + `ability.tryActivate() must return a string or null`, + `got: ${JSON.stringify(result)}`, + ); + } + } catch (err) { + fail(`ability.tryActivate() threw an error`, err.message); + } +} + +// ── reset() restores HP ── +monster.reset(); +monster.hp.takeDamage(50); +const hpBefore = monster.health; +monster.reset(); +if (monster.health === monster.hp.max && monster.health > hpBefore) { + ok(`reset() fully restores HP (${hpBefore} → ${monster.health})`); +} else if (monster.health === monster.hp.max) { + ok( + `reset() restores HP to max (was already full or took no damage — verify manually)`, + ); +} else { + fail( + `reset() did not restore HP to max`, + `expected ${monster.hp.max}, got ${monster.health}`, + ); +} + +// ── Monster can die ── +monster.reset(); +const target2 = new MonsterClass(); +let turns = 0; +while (target2.isAlive() && turns < 500) { + monster.attack(target2); + turns++; +} +if (!target2.isAlive()) { + ok(`Monster can die (took ${turns} attacks)`); +} else { + fail( + `Monster never died after 500 attacks`, + `check that takeDamage() reduces HP`, + ); +} + +// ── Summary ── +console.log(""); +if (failed === 0) { + console.log(green(bold(`All ${passed} checks passed! 🎉`))); + console.log( + `\n${yellow("Next step:")} add your monster to src/main.js and run ${yellow("npm run dev")} to see it fight!\n`, + ); +} else { + console.log(red(bold(`${failed} check(s) failed, ${passed} passed.`))); + console.log( + `\nFix the issues above, then run ${yellow("npm test")} again.\n`, + ); + process.exit(1); +} diff --git a/courses/frontend/advanced-javascript/week4/session-plan.md b/courses/frontend/advanced-javascript/week4/session-plan.md index 977f9d03..010a6069 100644 --- a/courses/frontend/advanced-javascript/week4/session-plan.md +++ b/courses/frontend/advanced-javascript/week4/session-plan.md @@ -2,29 +2,103 @@ ## Session Materials +- [Slides](./session-materials/js_oop_classes.pdf) – 41-slide deck covering all topics below - [Demo](./session-materials/demo/) – In-session live coding: plain-object motivation, `Comment` class, methods, flagged comments, then Errors / Web Components as “real world” context. **index.js** = worksheet; **index-solution.js** = reference. [README](./session-materials/demo/README.md). +- [Code inspiration](./session-materials/code-inspiration.md) – Snippets for the board or live coding ## Session Outline - - -Start VERY simple. Just a class that has few fields, no methods. Explain the diff from object to class. Explain instance etc. When they get that move on to class methods. **Only teach extends if they really are on top of things** otherwise just get them comfortable with classes :) if you can repeat a bit of promise, maybe when working with class that would be great. - -- Constructor - - [Code inspiration](./session-materials/code-inspiration.md#constructor) - - [Exercise](./session-materials/exercises.md#1-create-a-user-class) -- Instance - - [Code inspiration](./session-materials/code-inspiration.md#instance) - - [Exercise](./session-materials/exercises.md#2-create-an-instance-of-the-class) -- Methods (instance + static) - - [Code inspiration](./session-materials/code-inspiration.md#methods) - - [Code inspiration — static methods](./session-materials/code-inspiration.md#static-methods) (Promise as "you already use this") - - [Exercise](./session-materials/exercises.md#3-create-a-class-method) -- `this` - - Refers to the instance of the class. Do go into too much detail and edge cases. Avoid mentioning `bind`, `apply`, etc unless you find it super important, the trainees will just forget it anyway! -- [Exercise](./session-materials/exercises.md#4-creating-a-cv-class) -- Extend (Only if time!) - - [Code inspiration](./session-materials/code-inspiration.md#extending-built-ins-error-and-web-components) (`Error`, `ValidationError`, Web Components sketch — matches demo Part 4) +Start VERY simple — build the mental model before touching code. **Only teach Inheritance & Composition if trainees are solid on classes and methods.** It's the last major section and can be cut if needed. Design Patterns (slides 35–41) are optional/bonus only. + +### 1. The Mental Model + +Concepts before code. Get this right and the syntax will feel natural. + +- **Class vs Instance:** class = blueprint (the concept), instance = the real thing + - “Car” the concept vs that red Tesla parked outside + - One class → many instances, each with its own data +- **Properties, Methods & Static:** + - Properties = what it IS — the data that makes each instance unique + - Methods = what it DOES — behaviors that use or change properties + - Static = shared truths, same for ALL instances (not specific to one) +- Show the full picture (slide 6): blueprint on the left → two instances on the right + +### 2. From Object to Class + +Bridge from what trainees already know. + +- Start with an object literal: `const myCar = { brand: “Tesla”, drive() { ... } }` — fine for ONE car +- Problem: what if you need 50? Copy-pasting 50 objects is a nightmare → you need a blueprint → a class +- Show the class syntax: `class`, `constructor`, `this` + - `class` keyword defines the blueprint + - `constructor` runs automatically when you call `new` + - `this.___` assigns properties to the new instance +- What `new` does step by step (slide 10): creates empty object → sets `this` → runs constructor → returns object + - [Code inspiration — constructor](./session-materials/code-inspiration.md#constructor) + - [Code inspiration — instance](./session-materials/code-inspiration.md#instance) +- **[Exercise 1: User class with DOM rendering](./session-materials/exercises.md#1-user-class-with-dom-rendering)** — parts 1 & 2 (create class + instantiate) + +### 3. Methods & `this` + +- Adding methods: functions defined inside the class, no `function` keyword needed +- **`this` = the thing left of the dot** — that's all they need to know + - `tesla.drive(100)` → `this` is `tesla`; `honda.drive(60)` → `this` is `honda` + - Same method, different instance → different `this` → different result + - Avoid going into edge cases (`bind`, `apply`, `call`) — trainees will forget it immediately +- Methods calling other methods via `this.method()` +- Async methods: just add the `async` keyword — works exactly like regular async functions, returns a Promise +- Static methods & properties: belong to the class itself, not any instance + - Called on the class name: `Car.numberOfWheels` ✓ vs `myCar.numberOfWheels` ✗ + - Bridge to Promises: `Promise.resolve()`, `Promise.all()` — they already use static methods! + - [Code inspiration — static methods](./session-materials/code-inspiration.md#static-methods) +- [Code inspiration — methods](./session-materials/code-inspiration.md#methods) +- **[Exercise 1: User class with DOM rendering](./session-materials/exercises.md#1-user-class-with-dom-rendering)** — part 3 (add `getFullName` + `render()`) +- **[Exercise 2: Creating a CV class](./session-materials/exercises.md#2-creating-a-cv-class)** + +### 4. Design Challenge: FoodDash + +Paper only — no code. This builds design thinking and sets up the inheritance discussion. + +- **Brief:** food delivery app — customers browse restaurants, add items, driver delivers +- **Rules:** paper only; for each class: name + properties + methods; show relationships between classes +- 8 minutes in small groups, then each group presents their design +- Discussion questions to guide debrief (slide 21): + - Which classes did different teams create? + - Did anyone make `Driver extends User`? `Customer extends User`? + - How did you handle the relationship between `Order` and `Restaurant`? + - Where did shared behavior come up? How did you handle it? +- That last question leads naturally into the next section +- **[Exercise 3: Design Challenge: FoodDash](./session-materials/exercises.md#3-design-challenge-fooddash)** + +### 5. Inheritance & Composition + +**Only teach this section if trainees are solid on classes and methods.** + +- **Inheritance: `extends` and `super()`** + - A child class IS A type of the parent — gets all parent properties and methods + - `super(...)` calls the parent's constructor — must come before using `this` + - `extends` = “is-a”: a Car IS A Vehicle + - [Code inspiration — inheritance](./session-materials/code-inspiration.md#inheritance) +- **When inheritance works:** clean “is-a” relationship, stable parent, max one level deep + - Good examples: `Vehicle → Car, Truck`; `Shape → Circle, Rectangle`; `Account → CheckingAccount, SavingsAccount` +- **When inheritance gets awkward:** `ElectricCar extends Vehicle` — forced to override `refuel()` with an error + - Adding `charge()` to `Vehicle` breaks gas cars; overriding to disable is a code smell +- **Composition: “has-a” instead of “is-a”** + - Car HAS an Engine, HAS a GPS — create parts as classes and delegate to them + - [Code inspiration — composition](./session-materials/code-inspiration.md#composition) +- **Passing dependencies in:** inject the engine from outside — swap behaviors without changing the class +- **Back to FoodDash:** inheritance approach hits a wall (a driver who is also a customer — JS has no multiple inheritance); composition handles it cleanly +- **Rule of thumb:** favor composition; use inheritance only when there's a clear, stable “is-a” relationship + +### 6. Optional: Design Patterns + +Bonus only — skip unless there is plenty of time and trainees are engaged. + +- **Strategy:** swap behavior by passing in a different object (the composition idea taken further) +- **Factory:** a function that hides `new` and setup details from the caller +- **Observer:** “when something happens, notify everyone who cares” — think `addEventListener`; how DOM events, Node EventEmitter, and most UI frameworks work +- **Singleton:** only one instance ever exists — useful for DB connections or config, but global state in disguise, use sparingly +- **[Bonus Exercise: Build FoodDash](./session-materials/exercises.md#bonus-build-fooddash)** ## Exercises