phase 10: schools — Mechanic, Naturalist, Navigator with unlock system and bonuses
Add 3 new schools with real scientific principles (Lever & Moment, Photosynthesis, Angular Measurement). Data-driven unlock conditions check codex elements, creature discoveries, and completed runs. Each school provides passive gameplay bonuses (projectile damage, movement speed, creature aggro range, reaction efficiency) applied through system multiplier parameters. CradleScene shows all 4 schools with locked ones grayed out and showing unlock hints. 24 new tests (511 total). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession }
|
||||
import { getGraphStats } from '../mycelium/graph';
|
||||
import type { BonusEffect } from '../mycelium/types';
|
||||
|
||||
const schools = schoolsData as SchoolData[];
|
||||
const schools = schoolsData as unknown as SchoolData[];
|
||||
const biomes = biomeDataArray as BiomeData[];
|
||||
|
||||
export class CradleScene extends Phaser.Scene {
|
||||
@@ -155,29 +155,34 @@ export class CradleScene extends Phaser.Scene {
|
||||
// Biome selection row
|
||||
this.createBiomeSelector(cx, 105);
|
||||
|
||||
// School cards
|
||||
const cardWidth = 320;
|
||||
// School cards — show ALL schools, grayed out if locked
|
||||
const cardWidth = 280;
|
||||
const cardHeight = 220;
|
||||
const startY = 150;
|
||||
const unlockedSchools = schools.filter(s => isSchoolUnlocked(this.meta, s.id));
|
||||
const cardSpacing = 12;
|
||||
|
||||
for (let i = 0; i < unlockedSchools.length; i++) {
|
||||
const school = unlockedSchools[i];
|
||||
const cardX = cx - (unlockedSchools.length - 1) * (cardWidth + 20) / 2 + i * (cardWidth + 20);
|
||||
for (let i = 0; i < schools.length; i++) {
|
||||
const school = schools[i];
|
||||
const unlocked = isSchoolUnlocked(this.meta, school.id);
|
||||
const totalCardWidth = schools.length * (cardWidth + cardSpacing) - cardSpacing;
|
||||
const cardX = cx - totalCardWidth / 2 + cardWidth / 2 + i * (cardWidth + cardSpacing);
|
||||
const cardY = startY + cardHeight / 2;
|
||||
|
||||
const container = this.add.container(cardX, cardY);
|
||||
container.setDepth(10);
|
||||
|
||||
const schoolColor = parseInt(school.color.replace('#', ''), 16);
|
||||
|
||||
// Card background
|
||||
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight, 0x0a1a0f, 0.9);
|
||||
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
|
||||
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight,
|
||||
unlocked ? 0x0a1a0f : 0x0a0a0a, 0.9);
|
||||
bg.setStrokeStyle(2, unlocked ? schoolColor : 0x333333);
|
||||
container.add(bg);
|
||||
|
||||
// School name
|
||||
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
|
||||
fontSize: '22px',
|
||||
color: school.color,
|
||||
fontSize: '20px',
|
||||
color: unlocked ? school.color : '#555555',
|
||||
fontFamily: 'monospace',
|
||||
fontStyle: 'bold',
|
||||
});
|
||||
@@ -185,71 +190,119 @@ export class CradleScene extends Phaser.Scene {
|
||||
container.add(nameText);
|
||||
|
||||
// Principle
|
||||
const principleText = this.add.text(0, -cardHeight / 2 + 50, school.principleRu, {
|
||||
fontSize: '12px',
|
||||
color: '#88aa88',
|
||||
const principleText = this.add.text(0, -cardHeight / 2 + 48, school.principleRu, {
|
||||
fontSize: '11px',
|
||||
color: unlocked ? '#88aa88' : '#444444',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
principleText.setOrigin(0.5);
|
||||
container.add(principleText);
|
||||
|
||||
// Starting elements
|
||||
const elemList = school.startingElements.map(sym => {
|
||||
const qty = school.startingQuantities[sym] ?? 1;
|
||||
return `${sym} ×${qty}`;
|
||||
}).join(' ');
|
||||
const elemText = this.add.text(0, -cardHeight / 2 + 80, elemList, {
|
||||
fontSize: '13px',
|
||||
color: '#aaffaa',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
elemText.setOrigin(0.5);
|
||||
container.add(elemText);
|
||||
if (unlocked) {
|
||||
// ─── Unlocked school: full info ───
|
||||
|
||||
// Playstyle
|
||||
const playText = this.add.text(0, -cardHeight / 2 + 120,
|
||||
this.wrapText(school.playstyleRu, 34), {
|
||||
// Starting elements
|
||||
const elemList = school.startingElements.map(sym => {
|
||||
const qty = school.startingQuantities[sym] ?? 1;
|
||||
return `${sym} ×${qty}`;
|
||||
}).join(' ');
|
||||
const elemText = this.add.text(0, -cardHeight / 2 + 72, elemList, {
|
||||
fontSize: '12px',
|
||||
color: '#778877',
|
||||
color: '#aaffaa',
|
||||
fontFamily: 'monospace',
|
||||
lineSpacing: 4,
|
||||
});
|
||||
playText.setOrigin(0.5, 0);
|
||||
container.add(playText);
|
||||
elemText.setOrigin(0.5);
|
||||
container.add(elemText);
|
||||
|
||||
// Description
|
||||
const descText = this.add.text(0, cardHeight / 2 - 60,
|
||||
this.wrapText(school.descriptionRu, 34), {
|
||||
fontSize: '11px',
|
||||
color: '#556655',
|
||||
fontFamily: 'monospace',
|
||||
lineSpacing: 3,
|
||||
// Bonus indicator
|
||||
const bonusKeys = Object.keys(school.bonuses);
|
||||
if (bonusKeys.length > 0) {
|
||||
const bonusLabels: Record<string, string> = {
|
||||
reactionEfficiency: 'Реакции',
|
||||
projectileDamage: 'Урон',
|
||||
creatureAggroRange: 'Агрессия',
|
||||
movementSpeed: 'Скорость',
|
||||
};
|
||||
const bonusText = bonusKeys.map(k => {
|
||||
const label = bonusLabels[k] ?? k;
|
||||
const val = school.bonuses[k];
|
||||
const pct = val >= 1 ? `+${Math.round((val - 1) * 100)}%` : `-${Math.round((1 - val) * 100)}%`;
|
||||
return `${label} ${pct}`;
|
||||
}).join(' ');
|
||||
const bText = this.add.text(0, -cardHeight / 2 + 95, bonusText, {
|
||||
fontSize: '11px',
|
||||
color: '#ffcc44',
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
bText.setOrigin(0.5);
|
||||
container.add(bText);
|
||||
}
|
||||
|
||||
// Playstyle
|
||||
const playText = this.add.text(0, -cardHeight / 2 + 118,
|
||||
this.wrapText(school.playstyleRu, 30), {
|
||||
fontSize: '11px',
|
||||
color: '#778877',
|
||||
fontFamily: 'monospace',
|
||||
lineSpacing: 3,
|
||||
});
|
||||
playText.setOrigin(0.5, 0);
|
||||
container.add(playText);
|
||||
|
||||
// Description
|
||||
const descText = this.add.text(0, cardHeight / 2 - 55,
|
||||
this.wrapText(school.descriptionRu, 30), {
|
||||
fontSize: '10px',
|
||||
color: '#556655',
|
||||
fontFamily: 'monospace',
|
||||
lineSpacing: 2,
|
||||
});
|
||||
descText.setOrigin(0.5, 0);
|
||||
container.add(descText);
|
||||
|
||||
// Click to select
|
||||
bg.setInteractive({ useHandCursor: true });
|
||||
bg.on('pointerover', () => {
|
||||
bg.setStrokeStyle(3, 0x00ff88);
|
||||
this.selectedIndex = i;
|
||||
});
|
||||
descText.setOrigin(0.5, 0);
|
||||
container.add(descText);
|
||||
bg.on('pointerout', () => {
|
||||
bg.setStrokeStyle(2, schoolColor);
|
||||
});
|
||||
bg.on('pointerdown', () => {
|
||||
this.startRun(school);
|
||||
});
|
||||
} else {
|
||||
// ─── Locked school: grayed out with unlock hint ───
|
||||
|
||||
// Lock icon
|
||||
const lockText = this.add.text(0, -10, '🔒', {
|
||||
fontSize: '32px',
|
||||
});
|
||||
lockText.setOrigin(0.5);
|
||||
container.add(lockText);
|
||||
|
||||
// Unlock hint
|
||||
const hint = school.unlockCondition?.hintRu ?? 'Заблокировано';
|
||||
const hintText = this.add.text(0, 30, hint, {
|
||||
fontSize: '11px',
|
||||
color: '#666666',
|
||||
fontFamily: 'monospace',
|
||||
align: 'center',
|
||||
});
|
||||
hintText.setOrigin(0.5);
|
||||
container.add(hintText);
|
||||
}
|
||||
|
||||
// Fade in
|
||||
container.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
alpha: 1,
|
||||
alpha: unlocked ? 1 : 0.6,
|
||||
duration: 600,
|
||||
delay: 200 + i * 150,
|
||||
});
|
||||
|
||||
// Click to select
|
||||
bg.setInteractive({ useHandCursor: true });
|
||||
bg.on('pointerover', () => {
|
||||
bg.setStrokeStyle(3, 0x00ff88);
|
||||
this.selectedIndex = i;
|
||||
});
|
||||
bg.on('pointerout', () => {
|
||||
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
|
||||
});
|
||||
bg.on('pointerdown', () => {
|
||||
this.startRun(school);
|
||||
});
|
||||
|
||||
this.schoolCards.push(container);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,9 @@ import {
|
||||
import { query } from 'bitecs';
|
||||
|
||||
// Run cycle imports
|
||||
import type { MetaState, SchoolData, RunState } from '../run/types';
|
||||
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
|
||||
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
|
||||
import { getSchoolBonuses } from '../run/meta';
|
||||
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
|
||||
import {
|
||||
createCrisisState,
|
||||
@@ -138,6 +139,9 @@ export class GameScene extends Phaser.Scene {
|
||||
// Biome selection
|
||||
private biomeId = 'catalytic-wastes';
|
||||
|
||||
// School bonuses (resolved from school data)
|
||||
private schoolBonuses!: ResolvedSchoolBonuses;
|
||||
|
||||
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
|
||||
this.meta = data.meta;
|
||||
this.runState = createRunState(data.runId, data.schoolId);
|
||||
@@ -148,6 +152,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.memoryFlashTimer = 0;
|
||||
this.purchasedEffects = data.purchasedEffects ?? [];
|
||||
this.biomeId = data.biomeId ?? 'catalytic-wastes';
|
||||
this.schoolBonuses = getSchoolBonuses(data.schoolId);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@@ -354,7 +359,7 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
/** Give the player their school's starting elements */
|
||||
private giveStartingKit(): void {
|
||||
const schools = schoolsData as SchoolData[];
|
||||
const schools = schoolsData as unknown as SchoolData[];
|
||||
const school = schools.find(s => s.id === this.runState.schoolId);
|
||||
if (!school) return;
|
||||
|
||||
@@ -389,8 +394,8 @@ export class GameScene extends Phaser.Scene {
|
||||
interact: this.keys.E.isDown,
|
||||
};
|
||||
|
||||
// 3. Player input → velocity
|
||||
playerInputSystem(this.gameWorld.world, input);
|
||||
// 3. Player input → velocity (Navigator school gets speed bonus)
|
||||
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
|
||||
|
||||
// 4. Movement (all entities including projectiles)
|
||||
movementSystem(this.gameWorld.world, delta);
|
||||
@@ -454,10 +459,11 @@ export class GameScene extends Phaser.Scene {
|
||||
this.tryLaunchProjectile();
|
||||
}
|
||||
|
||||
// 9a. Creature AI — adjust aggression based on escalation
|
||||
// 9a. Creature AI — aggro range affected by Naturalist school bonus
|
||||
aiSystem(
|
||||
this.gameWorld.world, delta,
|
||||
this.speciesLookup, this.gameWorld.time.tick,
|
||||
this.schoolBonuses.creatureAggroRange,
|
||||
);
|
||||
|
||||
// 9b. Creature metabolism (feeding, energy drain)
|
||||
@@ -486,9 +492,10 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
// 9e. Projectile-creature collision
|
||||
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
|
||||
creatureProjectileSystem(
|
||||
this.gameWorld.world, this.projectileData, this.speciesLookup,
|
||||
this.schoolBonuses.projectileDamage,
|
||||
);
|
||||
|
||||
// 9f. Creature attacks on player
|
||||
|
||||
Reference in New Issue
Block a user