diff --git a/AGENTS.md b/AGENTS.md index 2fb2653..842778e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ src/ AudioManager.js / sounds.js public/ # browser runtime assets only tests/ - game.spec.js / site.spec.js # 251 Playwright browser tests + game.spec.js / site.spec.js # 261 Playwright browser tests ``` See [docs/reference-data.md](docs/reference-data.md) for sprite coordinates, wonder IDs, city screen layout, and map file format. diff --git a/CHANGELOG.md b/CHANGELOG.md index 776c232..91bd80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.6.1] - 2026-08-28 + +### Added + +- Added **Game → Report Bug...**, which creates one ZIP containing a PNG of the + current game view, a restorable game-state snapshot, renderer/view state, + browser diagnostics, and handling instructions. +- Added Download, system Share/Email, email-draft fallback, and prefilled GitHub + issue routes for bug reports without storing GitHub credentials. +- Added a responsive in-game preview to the project landing page and refreshed + its gameplay and project-page screenshots from the corrected renderer. + +### Changed + +- Restored the MGE-sized city production chooser, original silver listbox, + Times headings, row geometry, unit/building icons, and fixed button layout. +- Size-one cities continue to offer Settlers and Engineers, matching MGE, but + retain the completed production and shields until the city grows. +- Active units in cities now flash at the original 200ms cadence over the city + and garrison flag instead of using a modern selection ring. +- Expanded browser coverage to 261 Playwright tests. + +### Fixed + +- Terrain and fog masks now respect transparent chroma-key pixels, removing the + black dotted diamonds that appeared around every map square. +- Reveal-map mode no longer applies fog-edge dithering, and its minimap now + shows the complete terrain, including blue ocean tiles, without fog. +- Corrected minimap terrain lookup so ocean and land use their intended MGE + colours in normal play as well as reveal mode. +- Research chooser headings now widen the compact MGE dialog only when needed, + preventing “What discovery shall our wise men pursue?” from overflowing. +- City garrison flags use the original marker offset and active units visibly + animate over occupied cities. + ## [0.6.0] - 2026-08-28 ### Added diff --git a/README.md b/README.md index 46c3451..18dbc86 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ownership attestation locally in your browser. - Original city, research, Civilopedia, throne-room, advisor, score, and map UI - Save/load, scenarios, diplomacy, research, combat, AI, Wonders, spaceship, government, trade, and late-game mechanics -- 251 Playwright browser regression tests +- 261 Playwright browser regression tests - Production build deployable beneath the GitHub Pages `/civ2/` project path This is a playable public beta, not a claim that every undocumented MGE edge @@ -52,6 +52,22 @@ npm run build:pages npm run preview:pages ``` +## Reporting bugs + +Choose **Game → Report Bug...** inside a running game. The app creates one ZIP +containing a screenshot, a restorable game-state snapshot, the current camera +and open-screen state, and browser/viewport diagnostics. + +- **Download ZIP** saves the report locally. +- **Share / Email** uses the browser or operating system share sheet when file + sharing is supported. Otherwise it downloads the ZIP and opens an email draft + that tells you which file to attach. +- **GitHub Issue** downloads the same ZIP and opens a prepared issue. Attach the + downloaded file before submitting it. + +The static app never asks for or stores GitHub credentials. Bug-report ZIPs can +contain original MGE artwork, so they should be shared only for diagnosis. + ## Repository layout ```text diff --git a/docs/screenshots/project-page.jpg b/docs/screenshots/project-page.jpg index 323e675..ac91a04 100644 Binary files a/docs/screenshots/project-page.jpg and b/docs/screenshots/project-page.jpg differ diff --git a/index.html b/index.html index 6f6281f..e5c2b2f 100644 --- a/index.html +++ b/index.html @@ -16,14 +16,20 @@ CIVILIZATION II
AboutSource
-
-

Multiplayer Gold Edition · Recreated for the browser

-

Build a civilization
to stand the test of time.

-

A graphics-first recreation of the 1998 classic, preserving its original interface, rules, music, movies, and unmistakable Windows-era character.

-
- Open the game - Before you play +
+
+

Multiplayer Gold Edition · Recreated for the browser

+

Build a civilization
to stand the test of time.

+

A graphics-first recreation of the 1998 classic, preserving its original interface, rules, music, movies, and unmistakable Windows-era character.

+
+
+ Gameplay view showing the city of Rome and three Roman warrior units on the map +
Current browser build · In-game view
+
@@ -38,7 +44,7 @@

Build a civilization
to stand the test of time.

-
251browser tests
+
261browser tests
59original movies supported
7world maps
BSD-3project code
diff --git a/package-lock.json b/package-lock.json index eb486fe..36cd42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "civ2-web", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "civ2-web", - "version": "0.6.0", + "version": "0.6.1", "devDependencies": { "@playwright/test": "^1.58.2", "sharp": "^0.35.4", diff --git a/package.json b/package.json index d761904..6a97087 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "civ2-web", - "version": "0.6.0", + "version": "0.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/public/site/gameplay.jpg b/public/site/gameplay.jpg new file mode 100644 index 0000000..73cb777 Binary files /dev/null and b/public/site/gameplay.jpg differ diff --git a/src/engine/GameState.js b/src/engine/GameState.js index 74c1413..ae7a2ca 100644 --- a/src/engine/GameState.js +++ b/src/engine/GameState.js @@ -2068,9 +2068,8 @@ export class GameState { } else { city.shields += yields.shields; const cost = this._productionCost(city.production); - if (city.shields >= cost) { + if (city.shields >= cost && this._completeProduction(city)) { city.shields = Math.max(0, city.shields - cost); - this._completeProduction(city); } } } @@ -2532,7 +2531,9 @@ export class GameState { if (type === 'unit') { let spawnCol = city.col, spawnRow = city.row; const unitData = UNITS[id]; - if (unitData.role === 5 && city.size <= 1) return; + // MGE keeps Settlers/Engineers in the production chooser at size 1, + // but holds the completed item (and its shields) until the city grows. + if (unitData.role === 5 && city.size <= 1) return false; if (unitData.domain === 2) { // Sea units must spawn on an adjacent ocean tile (coastal city) const oceanAdj = neighbours(city.col, city.row, this.mapCols).find(n => { @@ -2670,6 +2671,7 @@ export class GameState { } else { city.production = null; } + return true; } // ─── Research ────────────────────────────────────────────────────────────── @@ -2723,8 +2725,6 @@ export class GameState { if (u.obsoletedBy !== -1 && civ.advances.has(u.obsoletedBy)) continue; // Nuclear Missile (id=45) requires Manhattan Project (62) to have been built by any civ if (u.id === 45 && !this._manhattanBuilt) continue; - // Settlers/Engineers (role 5) require city size > 1 (axx0 UnitProductionOrder.cs:28-31) - if (u.role === 5 && city.size <= 1) continue; items.push({ type: 'unit', id: u.id, name: u.name, cost: u.cost * 10 }); } diff --git a/src/render/MapRenderer.js b/src/render/MapRenderer.js index 66cad10..da516a3 100644 --- a/src/render/MapRenderer.js +++ b/src/render/MapRenderer.js @@ -25,6 +25,7 @@ import { applyWizardMixin } from './mixins/WizardMixin.js'; import { applyCityScreenMixin } from './mixins/CityScreenMixin.js'; import { applySidebarMixin } from './mixins/SidebarMixin.js'; import { applyTerrainMixin } from './mixins/TerrainMixin.js'; +import { applyBugReportMixin } from './mixins/BugReportMixin.js'; import { GameState } from '../engine/GameState.js'; import { Civ2SaveLoader } from '../engine/Civ2SaveLoader.js'; import { MapLoader } from '../engine/MapLoader.js'; @@ -53,6 +54,8 @@ const MENUS = [ { label: 'Save As MGE .SAV', action: 'game_save_sav' }, { label: 'Load Game', action: 'game_load', shortcut: 'Ctrl+L' }, null, + { label: 'Report Bug...', action: 'game_reportbug' }, + null, { label: 'Retire', action: 'game_retire', shortcut: 'Ctrl+R' }, { label: 'Quit', action: 'game_quit', shortcut: 'Ctrl+Q' }, ]}, @@ -371,6 +374,7 @@ export class MapRenderer { this._menuBarRects = []; // [{x, w, menuIdx}] — populated during draw this._menuItemRects = []; // [{x,y,w,h,action,disabled}] — current dropdown this._menuHoverIdx = null; // index into _menuItemRects + this._bugReportDialog = null; // Title screen state this._titleScreen = false; @@ -925,6 +929,7 @@ export class MapRenderer { if (this._titleScreen) { this._handleTitleScreenKey(e); return; } // ── Wizard intercepts all keys when active ────────────────────────────── if (this._wizard) { this._handleWizardKey(e); return; } + if (this._bugReportDialog) { this._handleBugReportKey(e); return; } if (this._editCityDialog) { this._handleEditCityKey(e); return; } if (this._editTechsDialog) { this._handleEditTechsKey(e); return; } if (this._editUnitDialog) { this._handleEditUnitKey(e); return; } @@ -965,6 +970,7 @@ export class MapRenderer { const gs = this.gameState; // Modal dialogs first + if (this._bugReportDialog) { this._bugReportDialog = null; return true; } if (this._civilopedia) { this._civilopedia = null; return true; } if (this._cityScreen && this._cityScreenProdList) { this._cityScreenProdList = false; @@ -1443,6 +1449,11 @@ export class MapRenderer { return; } + if (this._bugReportDialog) { + this._handleBugReportClick(px, py); + return; + } + // ── Retirement flow (POWERgraph → Score → HoF) ────────────────────────── if (this._retireStage) { this._handleRetireFlowClick(px, py); @@ -2468,6 +2479,20 @@ export class MapRenderer { // Collect cities for name rendering in a separate last pass if (!this._cityNameQueue) this._cityNameQueue = []; this._cityNameQueue.push({ city, x, y }); + + // In MGE the city and garrison flag remain in the base map, while + // the ready unit flashes on top as a separate animation frame. + // Queue it for the final map pass. The original MGE waiting frames + // then redraw city names over the unit. + const active = this.gameState.activeUnit; + const combatAtCity = this._combatAnim && + ((col === this._combatAnim.atkCol && row === this._combatAnim.atkRow) || + (col === this._combatAnim.defCol && row === this._combatAnim.defRow)); + if (v === 2 && active?.col === col && active?.row === row && + (!this._moveAnim || this._moveAnim.unit !== active) && !combatAtCity) { + if (!this._activeCityUnitQueue) this._activeCityUnitQueue = []; + this._activeCityUnitQueue.push({ unit: active, x, y }); + } } if (v === 2) { @@ -2563,7 +2588,17 @@ export class MapRenderer { ctx.strokeRect(fx - 2, fy - 2, tileWS + 4, tileHS + 4); } - // Pass LAST — city names drawn on top of everything (axx0 Draw.Map.cs:135-140) + // Active units in cities are animation elements in MGE: their sprite and + // shield blink over the otherwise-static city and flag. + if (this._activeCityUnitQueue) { + for (const { unit, x: ux, y: uy } of this._activeCityUnitQueue) { + this._drawUnit(ctx, unit, ux, uy, spritesReady); + } + this._activeCityUnitQueue = null; + } + + // Pass LAST — city names remain readable above units, exactly as MGE's + // WaitingAnimation redraws them after its active-unit frame. if (this._cityNameQueue) { for (const { city, x: cx, y: cy } of this._cityNameQueue) { this._drawCityName(ctx, city, cx, cy); @@ -2635,6 +2670,10 @@ export class MapRenderer { // Game-over screen if (!this._wizard && !this._retireStage && this.gameState.gameOver) this._drawGameOver(ctx, canvasW, canvasH); + + // Modern support feature, intentionally topmost so its controls cannot be + // obscured by the MGE screens included in the captured report. + if (this._bugReportDialog) this._drawBugReportDialog(ctx, canvasW, canvasH); } _drawGameOver(ctx, canvasW, canvasH) { @@ -3136,6 +3175,9 @@ export class MapRenderer { case 'game_load': this._loadGame(); return true; + case 'game_reportbug': + this._openBugReportDialog(); + return true; case 'game_options': this._gameOptionsDialog = true; this._gameOptionsRects = []; @@ -3829,6 +3871,7 @@ export class MapRenderer { this._negotiationScreen = null; this._aiPeaceProposal = null; this._captureDialog = null; + this._bugReportDialog = null; this._stopWonderVideo(); this._wonderSplash = null; this._wonderVideo = null; @@ -5047,3 +5090,4 @@ applyWizardMixin(MapRenderer); applyDialogsMixin(MapRenderer); applyAdvisorsMixin(MapRenderer); applyInfoScreensMixin(MapRenderer); +applyBugReportMixin(MapRenderer); diff --git a/src/render/mixins/BugReportMixin.js b/src/render/mixins/BugReportMixin.js new file mode 100644 index 0000000..c496a7c --- /dev/null +++ b/src/render/mixins/BugReportMixin.js @@ -0,0 +1,285 @@ +import { FONT } from '../renderConstants.js'; +import { createStoredZip } from '../../utils/bugReportZip.js'; + +const GITHUB_ISSUES_URL = 'https://github.com/wan0net/civ2/issues/new'; + +function downloadFile(file) { + const url = URL.createObjectURL(file); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = file.name; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** @param {typeof import('../MapRenderer.js').MapRenderer} MapRenderer */ +export function applyBugReportMixin(MapRenderer) { + MapRenderer.prototype._bugReportRendererState = function() { + const city = this._cityScreen; + const active = this.gameState.activeUnit; + return { + viewX: this.viewX, + viewY: this.viewY, + zoomLevel: this._zoomLevel, + viewOnlyMode: this._viewOnlyMode, + showGrid: this._showGrid, + showHiddenTerrain: this._showHiddenTerrain, + activeUnitId: active?.id ?? null, + hoveredTile: this._hoveredTile ? { ...this._hoveredTile } : null, + cityScreenId: city?.id ?? null, + cityProductionListOpen: !!this._cityScreenProdList, + civilopedia: this._civilopedia ? { + tab: this._civilopedia.tab, + selIdx: this._civilopedia.selIdx, + scroll: this._civilopedia.scroll, + mode: this._civilopedia.mode, + } : null, + researchChooserOpen: !!this._researchChooser, + researchGoalOpen: !!this._researchGoalDialog, + openScreens: [ + '_scienceAdvisor', '_tradeAdvisor', '_domesticAdvisor', '_militaryAdvisor', + '_attitudeAdvisor', '_demographicsScreen', '_top5Cities', '_wondersList', + '_palaceView', '_throneRoom', '_spaceshipViewer', '_highCouncil', + ].filter(key => !!this[key]), + }; + }; + + MapRenderer.prototype._buildBugReportFile = async function(rendererState, createdAt = new Date()) { + const canvas = document.getElementById('game-canvas'); + if (!canvas) throw new Error('Game canvas is unavailable.'); + // Snapshot gameplay first, then render during the same JavaScript turn so + // the screenshot and save data describe one state rather than adjacent + // animation/update frames. + const gameState = this.gameState.toSaveData(); + const gameYear = this.gameState.year; + + // Render the exact underlying scene without the Game menu or report dialog. + const capture = document.createElement('canvas'); + capture.width = canvas.width; + capture.height = canvas.height; + this.render(capture.getContext('2d'), capture.width, capture.height); + const screenshot = await new Promise((resolve, reject) => { + capture.toBlob(blob => blob ? resolve(blob) : reject(new Error('Screenshot capture failed.')), 'image/png'); + }); + + const stamp = createdAt.toISOString().replace(/[:.]/g, '-'); + const filename = `civ2-bug-report-${stamp}.zip`; + const report = { + format: 'civ2-web-bug-report', + formatVersion: 1, + createdAt: createdAt.toISOString(), + page: `${location.origin}${location.pathname}`, + browser: navigator.userAgent, + viewport: { + canvasWidth: canvas.width, + canvasHeight: canvas.height, + cssWidth: canvas.clientWidth, + cssHeight: canvas.clientHeight, + devicePixelRatio: window.devicePixelRatio, + }, + summary: { + turn: gameState.turn, + year: gameYear, + activeCivilization: gameState.activeCivIdx, + activeUnitId: gameState.activeUnitId, + mapCols: gameState.mapCols, + mapRows: gameState.mapRows, + }, + rendererState, + }; + const instructions = [ + 'Civilization II Browser Recreation - Bug Report', + '', + 'screenshot.png Exact visible state when Report Bug was chosen.', + 'game-state.json Full restorable GameState.toSaveData() snapshot.', + 'report.json View/camera state, browser details, and summary.', + '', + 'Attach this ZIP to a GitHub issue or email. Do not unpack it first.', + 'The screenshot may contain original MGE artwork; share only for bug diagnosis.', + '', + ].join('\n'); + const archive = createStoredZip([ + { name: 'screenshot.png', data: new Uint8Array(await screenshot.arrayBuffer()) }, + { name: 'game-state.json', data: JSON.stringify(gameState, null, 2) }, + { name: 'report.json', data: JSON.stringify(report, null, 2) }, + { name: 'README.txt', data: instructions }, + ], createdAt); + return new File([archive], filename, { type: 'application/zip', lastModified: createdAt.getTime() }); + }; + + MapRenderer.prototype._openBugReportDialog = function() { + if (this._bugReportDialog) return; + const rendererState = this._bugReportRendererState(); + // Start the async capture before the dialog exists. _buildBugReportFile() + // renders synchronously up to canvas.toBlob(), so the PNG contains the + // precise game screen beneath this support UI rather than the UI itself. + const capturePromise = this._buildBugReportFile(rendererState); + const dialog = this._bugReportDialog = { + status: 'capturing', + message: 'Capturing screenshot and exact game state...', + file: null, + rects: [], + }; + dialog.promise = capturePromise + .then(file => { + if (this._bugReportDialog !== dialog) return file; + dialog.file = file; + dialog.status = 'ready'; + dialog.message = `Ready: ${file.name} (${formatBytes(file.size)})`; + return file; + }) + .catch(error => { + console.error('Bug report capture failed:', error); + if (this._bugReportDialog === dialog) { + dialog.status = 'error'; + dialog.message = `Capture failed: ${error.message}`; + } + return null; + }); + }; + + MapRenderer.prototype._downloadBugReport = function() { + const dialog = this._bugReportDialog; + if (!dialog?.file) return false; + downloadFile(dialog.file); + dialog.message = `Downloaded ${dialog.file.name}`; + return true; + }; + + MapRenderer.prototype._shareBugReport = async function() { + const dialog = this._bugReportDialog; + if (!dialog?.file) return false; + const shareData = { + title: 'Civilization II bug report', + text: 'Civ2 browser recreation bug report with screenshot and exact game state.', + files: [dialog.file], + }; + try { + if (navigator.canShare?.(shareData) && navigator.share) { + await navigator.share(shareData); + dialog.message = 'Report shared.'; + return true; + } + downloadFile(dialog.file); + const subject = 'Civilization II bug report'; + const body = [ + 'Please attach the downloaded Civ2 bug-report ZIP to this email.', + '', + `File: ${dialog.file.name}`, + `Turn: ${this.gameState.turn}`, + '', + 'The ZIP contains a screenshot, exact game state, view state, and diagnostics.', + ].join('\n'); + window.open(`mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`, + '_blank', 'noopener,noreferrer'); + dialog.message = 'Email draft opened. Attach the downloaded ZIP before sending.'; + return false; + } catch (error) { + if (error?.name === 'AbortError') { + dialog.message = 'Sharing cancelled. The report is still ready.'; + return false; + } + console.error('Bug report sharing failed:', error); + dialog.message = 'Sharing failed. Use Download instead.'; + return false; + } + }; + + MapRenderer.prototype._openGitHubBugReport = function() { + const dialog = this._bugReportDialog; + if (!dialog?.file) return false; + downloadFile(dialog.file); + const reportName = dialog.file.name; + const turn = this.gameState.turn; + const body = [ + '## Bug description', + '', + '', + '', + '## Reproduction package', + '', + `Turn: ${turn}`, + `Please attach the downloaded \`${reportName}\` file to this issue.`, + 'It contains a screenshot, exact game state, view state, and diagnostics.', + ].join('\n'); + const url = `${GITHUB_ISSUES_URL}?title=${encodeURIComponent('[Bug] ')}&body=${encodeURIComponent(body)}`; + window.open(url, '_blank', 'noopener,noreferrer'); + dialog.message = 'GitHub opened. Attach the downloaded ZIP before submitting.'; + return true; + }; + + MapRenderer.prototype._drawBugReportDialog = function(ctx, canvasW, canvasH) { + const dialog = this._bugReportDialog; + if (!dialog) return; + const PW = Math.min(680, canvasW - 40); + const PH = 250; + const px = Math.round((canvasW - PW) / 2); + const py = Math.round((canvasH - PH) / 2); + ctx.fillStyle = 'rgba(0,0,0,0.48)'; + ctx.fillRect(0, 0, canvasW, canvasH); + this._drawCiv2Panel(ctx, px, py, PW, PH, 'Report a Bug'); + + ctx.font = FONT.BODY_TIMES; + this._panelText(ctx, 'Create one support package containing:', px + 24, py + 56); + const lines = [ + 'Screenshot of the exact visible game', + 'Full restorable game-state snapshot', + 'Camera, zoom, open-screen, browser, and viewport details', + ]; + lines.forEach((line, index) => this._panelText(ctx, `\u2022 ${line}`, px + 38, py + 82 + index * 22)); + ctx.font = FONT.BODY_SMALL; + this._panelText(ctx, 'GitHub opens a prepared issue. Share / Email uses system sharing or opens an email draft.', + px + 24, py + 156); + + ctx.font = FONT.SMALL_BOLD; + ctx.fillStyle = dialog.status === 'error' ? '#a00000' : '#000000'; + ctx.fillText(dialog.message, px + 24, py + 184, PW - 48); + + const labels = ['Download ZIP', 'Share / Email', 'GitHub Issue', 'Close']; + const gap = 10; + const bw = Math.floor((PW - 48 - gap * 3) / 4); + const by = py + PH - 39; + dialog.rects = []; + labels.forEach((label, index) => { + const bx = px + 24 + index * (bw + gap); + this._drawWin95Button(ctx, bx, by, bw, 26, label); + dialog.rects.push({ action: ['download', 'share', 'github', 'close'][index], x: bx, y: by, w: bw, h: 26 }); + }); + + if (dialog.status !== 'ready') { + for (const rect of dialog.rects.slice(0, 3)) { + ctx.fillStyle = 'rgba(192,192,192,0.58)'; + ctx.fillRect(rect.x + 2, rect.y + 2, rect.w - 4, rect.h - 4); + } + } + }; + + MapRenderer.prototype._handleBugReportClick = function(px, py) { + const dialog = this._bugReportDialog; + if (!dialog) return; + const hit = dialog.rects.find(rect => px >= rect.x && px < rect.x + rect.w && py >= rect.y && py < rect.y + rect.h); + if (!hit) return; + if (hit.action === 'close') { + this._bugReportDialog = null; + return; + } + if (dialog.status !== 'ready') return; + if (hit.action === 'download') this._downloadBugReport(); + if (hit.action === 'share') void this._shareBugReport(); + if (hit.action === 'github') this._openGitHubBugReport(); + }; + + MapRenderer.prototype._handleBugReportKey = function(event) { + if (event.key === 'Escape') this._bugReportDialog = null; + if (event.key === 'Enter' && this._bugReportDialog?.status === 'ready') this._downloadBugReport(); + }; +} diff --git a/src/render/mixins/CityScreenMixin.js b/src/render/mixins/CityScreenMixin.js index 37c22c7..a636dee 100644 --- a/src/render/mixins/CityScreenMixin.js +++ b/src/render/mixins/CityScreenMixin.js @@ -973,21 +973,29 @@ export function applyCityScreenMixin(MapRenderer) { return; } - // Game.txt @PRODUCTION: width=440, listbox (13 rows), Auto + Help; - // PopupBoxReader appends the standard OK button. + // Game.txt @PRODUCTION: width=440, listbox, Auto + Help; PopupBoxReader + // appends OK. MGE displays this as a fixed-size top-level dialog rather + // than shrinking it to the number of currently available items. The + // virtual geometry below is measured from the original 680x268 dialog. const items = this.gameState.availableProduction(city); if (!items.some(item => this._cityScreenProductionSelection?.type === item.type && this._cityScreenProductionSelection?.id === item.id)) { this._cityScreenProductionSelection = prod ?? items[0] ?? null; } - const rowH = 23; - const rowsVis = Math.max(1, Math.min(13, items.length)); - const OPW = 440; - const OPH = 34 + rowsVis * rowH + 34; + const rowH = 19; + const OPW = 544; + const OPH = 214; + const listTop = 28; + const listH = 152; + const rowsVis = Math.max(1, Math.min(8, Math.floor(listH / rowH))); const OPX = (640 - OPW) / 2; const OPY = (446 - OPH) / 2; this._cityProductionDialogRect = vfr(OPX, OPY, OPW, OPH); - ctx.fillStyle = '#9a9a9a'; - vfl(OPX, OPY, OPW, OPH); + if (this._innerWallpaper) { + this._tilePattern(ctx, this._innerWallpaper, vx(OPX), vy(OPY), vs(OPW), vs(OPH)); + } else { + ctx.fillStyle = '#c0c0c0'; + vfl(OPX, OPY, OPW, OPH); + } const bColors = ['#e3e3e3', '#696969', '#ffffff', '#a0a0a0', '#f0f0f0', '#dfdfdf', '#434343']; const bvx = x => vx(OPX + x), bvy = y => vy(OPY + y); const bvr = x => vx(OPX + OPW - x), bvb = y => vy(OPY + OPH - y); @@ -1007,16 +1015,29 @@ export function applyCityScreenMixin(MapRenderer) { ctx.stroke(); }); - ctx.fillStyle = '#8a8a8a'; - vfl(OPX + 5, OPY + 31, OPW - 10, rowsVis * rowH); - ctx.font = vfont(13); + // MGE listbox: flat silver field with a thin sunken edge. Empty space is + // retained below short early-game build lists. + ctx.fillStyle = '#c0c0c0'; + vfl(OPX + 5, OPY + listTop, OPW - 10, listH); + ctx.strokeStyle = '#404040'; + ctx.lineWidth = 1; + ctx.strokeRect(vx(OPX + 5) + 0.5, vy(OPY + listTop) + 0.5, + vs(OPW - 10) - 1, vs(listH) - 1); + ctx.strokeStyle = '#ffffff'; + ctx.strokeRect(vx(OPX + 6) + 0.5, vy(OPY + listTop + 1) + 0.5, + vs(OPW - 12) - 1, vs(listH - 2) - 1); + + // Original embossed Times title (not the gold city-screen heading). + ctx.font = `bold ${vs(15)}px ${FONT_TIMES}`; ctx.textAlign = 'center'; - shc(`What shall we build in ${city.name}?`, OPX + OPW / 2, OPY + 22, CLR.GOLD, C_HDR_SH); + ctx.fillStyle = '#ffffff'; + ctx.fillText(`What shall we build in ${city.name}?`, vx(OPX + OPW / 2) + 1, vy(OPY + 21) + 1); + ctx.fillStyle = '#3f3f3f'; + ctx.fillText(`What shall we build in ${city.name}?`, vx(OPX + OPW / 2), vy(OPY + 21)); ctx.textAlign = 'left'; this._cityScreenQueueModeRect = null; this._cityScreenTabRects = []; - const listY = OPY + 31; - const listH = rowsVis * rowH; + const listY = OPY + listTop; const scroll2 = Math.max(0, Math.min(this._cityScreenScroll, Math.max(0, items.length - rowsVis))); this._cityScreenScroll = scroll2; this._cityScreenItemRects = []; @@ -1024,38 +1045,39 @@ export function applyCityScreenMixin(MapRenderer) { const item = items[i + scroll2]; const iy = listY + i * rowH; const isSelected = this._cityScreenProductionSelection?.type === item.type && this._cityScreenProductionSelection?.id === item.id; - ctx.fillStyle = isSelected ? '#000080' : '#878787'; - vfl(OPX + 6, iy, OPW - 12, rowH - 1); + ctx.fillStyle = isSelected ? '#808080' : '#c0c0c0'; + vfl(OPX + 6, iy, OPW - 12, rowH); try { if (item.type === 'unit') { const spr = this.sprites.getSprite('units', Math.floor(item.id / 9), item.id % 9); - if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy), vs(30), vs(22)); + const imageShift = i % 2 === 1 ? 16 : 0; + if (spr) ctx.drawImage(spr, vx(OPX + 8 + imageShift), vy(iy), vs(30), vs(19)); } else { const wonder = item.id >= 39; const index = wonder ? item.id - 39 : item.id - 1; const cols = wonder ? 7 : 8; const spr = this.sprites.getRegionSprite('icons', 343 + (index % cols) * 37, (wonder ? 106 : 1) + Math.floor(index / cols) * 21, 36, 20); - if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy + 1), vs(36), vs(20)); + if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy + 1), vs(36), vs(17)); } } catch (e) { _warnOnce(`production-icon-${item.type}-${item.id}`, `Production icon unavailable: ${e.message}`); } - ctx.font = vtfont(12); + ctx.font = `bold ${vs(13)}px ${FONT_TIMES}`; ctx.fillStyle = isSelected ? '#ffffff' : '#000000'; - vtx(item.name, OPX + 49, iy + 16); + vtx(item.name, OPX + 70, iy + 15); const perTurn = Math.max(1, this.gameState.cityYields(city).shields); const turns = Math.max(1, Math.ceil(Math.max(0, item.cost - city.shields) / perTurn)); const unit = item.type === 'unit' ? UNITS[item.id] : null; const facts = unit ? `(${turns} Turns, ADM: ${unit.attack}/${unit.defense}/${unit.move} HP: ${unit.hp}/${unit.fp})` : `(${turns} Turns)`; - ctx.font = vtfont(10); + ctx.font = `bold ${vs(11)}px ${FONT_TIMES}`; ctx.fillStyle = isSelected ? '#ffffff' : '#222222'; ctx.textAlign = 'right'; - ctx.fillText(facts, vx(OPX + OPW - 11), vy(iy + 16)); + ctx.fillText(facts, vx(OPX + OPW - 9), vy(iy + 15)); ctx.textAlign = 'left'; - this._cityScreenItemRects.push({ ...vfr(OPX + 6, iy, OPW - 12, rowH - 1), item }); + this._cityScreenItemRects.push({ ...vfr(OPX + 6, iy, OPW - 12, rowH), item }); } if (items.length > rowsVis) { diff --git a/src/render/mixins/DialogsMixin.js b/src/render/mixins/DialogsMixin.js index 42d512c..9f7020e 100644 --- a/src/render/mixins/DialogsMixin.js +++ b/src/render/mixins/DialogsMixin.js @@ -1427,9 +1427,20 @@ export function applyDialogsMixin(MapRenderer) { ? available.filter(a => goalIds.includes(a.id)) : available; - // Game.txt @RESEARCH: width=300, listbox, Help + Goal. PopupBoxReader - // appends the standard OK button used to accept the highlighted row. - const PW = Math.min(300, canvasW - 20); + const scienceEpoch = Math.max(0, ...[...civ.advances].map(id => ADVANCES[id]?.epoch ?? 0)); + const title = goalIds + ? 'Then we should research' + : `What discovery shall our ${scienceEpoch >= 3 ? 'scientists' : 'wise men'} pursue?`; + + // Game.txt @RESEARCH requests a 300px-wide listbox, but Civ2's dynamic + // dialog sizing treats that as a minimum and widens the panel when its + // Times New Roman heading is longer. Keep the title inside the bevel while + // preserving the compact 300px panel for shorter headings. + ctx.save(); + ctx.font = FONT.TITLE_LARGE; + const titleWidth = Math.ceil(ctx.measureText(title).width) + 18; + ctx.restore(); + const PW = Math.min(Math.max(300, titleWidth), canvasW - 20); const rowsVis = Math.max(1, Math.min(16, avail.length)); const ITEM_H = 23; const HDR = 34; @@ -1439,10 +1450,6 @@ export function applyDialogsMixin(MapRenderer) { const py = Math.round((canvasH - PH) / 2); this._researchChooserRect = { x: px, y: py, w: PW, h: PH }; - const scienceEpoch = Math.max(0, ...[...civ.advances].map(id => ADVANCES[id]?.epoch ?? 0)); - const title = goalIds - ? 'Then we should research' - : `What discovery shall our ${scienceEpoch >= 3 ? 'scientists' : 'wise men'} pursue?`; this._drawCiv2Panel(ctx, px, py, PW, PH, title); const listY0 = py + HDR; diff --git a/src/render/mixins/SidebarMixin.js b/src/render/mixins/SidebarMixin.js index a94a46e..f151efc 100644 --- a/src/render/mixins/SidebarMixin.js +++ b/src/render/mixins/SidebarMixin.js @@ -80,17 +80,17 @@ export function applySidebarMixin(MapRenderer) { } else { for (let row = 0; row < this.mapRows; row++) { for (let col = 0; col < this.mapCols; col++) { - const v = vis[row][col]; + const v = this._showHiddenTerrain ? 2 : vis[row][col]; if (v === 0) continue; const t = tiles[row][col]; - const baseColor = t.terrain?.id === 7 ? CLR.MM_OCEAN : CLR.MM_LAND; + const baseColor = t?.id === 7 ? CLR.MM_OCEAN : CLR.MM_LAND; ctx.fillStyle = v === 2 ? baseColor : this._darkenHex(baseColor, 0.5); const px = mapX + col * mmTileW + (row % 2 ? Math.floor(mmTileW / 2) : 0); ctx.fillRect(px, mapY + row * mmTileH, mmTileW, mmTileH); } } for (const city of gs.cities) { - const v = vis[city.row]?.[city.col] ?? 0; + const v = this._showHiddenTerrain ? 2 : (vis[city.row]?.[city.col] ?? 0); if (v === 0) continue; const civ = gs.civs[city.civId]; const color = CIV_COLORS[civ?.data?.color ?? 0]; @@ -478,16 +478,21 @@ export function applySidebarMixin(MapRenderer) { const mapCol = ((Math.floor(mapColF) % mapCols) + mapCols) % mapCols; const tileKey = clampedRow * mapCols + mapCol; + const visibility = this._showHiddenTerrain + ? 2 + : (gs._visibility[clampedRow]?.[mapCol] ?? 0); let r, g, b; - if (citySet.has(tileKey)) { + if (visibility === 0) { + r = 0; g = 0; b = 0; + } else if (citySet.has(tileKey)) { const hex = cityColorMap.get(tileKey) || '#ffffff'; r = parseInt(hex.slice(1, 3), 16); g = parseInt(hex.slice(3, 5), 16); b = parseInt(hex.slice(5, 7), 16); } else { const tile = tiles[clampedRow]?.[mapCol]; - const terrainId = tile?.terrain?.id ?? 7; + const terrainId = tile?.id ?? 7; if (terrainId === 7) { r = 0; g = 0; b = 128; } else if (terrainId === 0) { @@ -499,7 +504,8 @@ export function applySidebarMixin(MapRenderer) { } } - const shade = 0.6 + 0.4 * Math.cos((localX - spanW / 2) / (spanW / 2)); + const exploredShade = visibility === 1 ? 0.5 : 1; + const shade = exploredShade * (0.6 + 0.4 * Math.cos((localX - spanW / 2) / (spanW / 2))); r = Math.round(r * shade); g = Math.round(g * shade); b = Math.round(b * shade); diff --git a/src/render/mixins/TerrainMixin.js b/src/render/mixins/TerrainMixin.js index 53a69fa..9aecfab 100644 --- a/src/render/mixins/TerrainMixin.js +++ b/src/render/mixins/TerrainMixin.js @@ -191,9 +191,11 @@ export function applyTerrainMixin(MapRenderer) { // Convert: non-transparent dark pixels → opaque white (mask pass-through) // Everything else → transparent (mask blocked) for (let p = 0; p < d.length; p += 4) { - const r = d[p], g = d[p+1], b = d[p+2]; - // Dark pixels (near-black) = dither dots where neighbour shows - const isDark = r < 80 && g < 80 && b < 80; + const r = d[p], g = d[p+1], b = d[p+2], a = d[p+3]; + // Dark, opaque pixels are the dither dots where the neighbour shows. + // SpriteManager has already keyed the mask's magenta and grey pixels + // to transparent black, so alpha must participate in this test. + const isDark = a > 0 && r < 80 && g < 80 && b < 80; if (isDark) { d[p] = 255; d[p+1] = 255; d[p+2] = 255; d[p+3] = 255; } else { @@ -386,6 +388,11 @@ export function applyTerrainMixin(MapRenderer) { * the only visual cue is a dithered edge at boundaries with unexplored areas. */ MapRenderer.prototype._drawFogDither = function(ctx, x, y, col, row) { + // axx0 MapImage.cs suppresses fog-edge dithering when MapRevealed is set. + // Otherwise every normally hidden tile sees four hidden neighbours and + // the revealed map becomes a distracting grid of black dotted diamonds. + if (this._showHiddenTerrain) return; + const vis = this.gameState._visibility; const o = row % 2; // Direct (diagonal) neighbours: NE, SE, SW, NW — matching axx0's directNeighbours order @@ -423,8 +430,12 @@ export function applyTerrainMixin(MapRenderer) { const maskData = maskCtx.getImageData(0, 0, hw, hh); const d = maskData.data; for (let p = 0; p < d.length; p += 4) { - const r = d[p], g = d[p+1], b = d[p+2]; - const isDark = r < 80 && g < 80 && b < 80; + const r = d[p], g = d[p+1], b = d[p+2], a = d[p+3]; + // SpriteManager has already chroma-keyed the magenta/grey guide + // pixels. Canvas reports those transparent pixels as black, so + // alpha must participate in the test or the whole quadrant turns + // into fog instead of retaining the sparse MGE dither dots. + const isDark = a > 0 && r < 80 && g < 80 && b < 80; d[p] = 0; d[p+1] = 0; d[p+2] = 0; d[p+3] = isDark ? 128 : 0; } @@ -775,13 +786,14 @@ export function applyTerrainMixin(MapRenderer) { ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; - // Flag sprite if units are present in the city (axx0 Draw.City.cs:122-129) - // axx0: dest.X + (flagLoc.X - 3).ZoomScale(zoom) + // Flag sprite if units are present in the city. The marker is the + // flag's left edge; only its Y position is raised by flagHeight - 5 + // (axx0 BaseGameView.cs:244-247). const unitsPresent = gs.unitsAt(city.col, city.row).length > 0; if (unitsPresent && this._civFlagSprites?.[civColorIdx]) { const flagSpr = this._civFlagSprites[civColorIdx]; ctx.drawImage(flagSpr, - destX + (flagLoc.x - 3) * scale, + destX + flagLoc.x * scale, destY + (flagLoc.y - 17) * scale, 14 * scale, 22 * scale); } @@ -838,8 +850,8 @@ export function applyTerrainMixin(MapRenderer) { const isActive = unit === this.gameState.activeUnit; const isEnemy = unit.civId !== 0; - // Active unit blinks: 500ms on / 500ms off - const blinkVisible = !isActive || Math.floor(this._blinkTime / 500) % 2 === 0; + // Original MGE waiting animation: 200ms on / 200ms off. + const blinkVisible = !isActive || Math.floor(this._blinkTime / 200) % 2 === 0; if (spritesReady) { try { @@ -869,7 +881,6 @@ export function applyTerrainMixin(MapRenderer) { } if (unit.buildTask) this._drawBuildProgress(ctx, unit, x, y); - if (isActive) this._drawSelectionRing(ctx, x, y); return; } catch (e) { _warnOnce('unit:' + unit.id, 'Unit sprite unavailable: ' + e.message); diff --git a/src/site.css b/src/site.css index 888db04..057ec62 100644 --- a/src/site.css +++ b/src/site.css @@ -14,9 +14,10 @@ a { color: inherit; } .nav a { text-decoration: none; } .nav > div { display: flex; gap: 28px; color: #bbb7aa; font-size: 14px; } .wordmark { font: 700 18px Tinos, 'Times New Roman', serif; letter-spacing: .16em; color: #e0bc62; } -.hero-copy { position: relative; z-index: 2; width: min(770px, 94%); padding: 135px 0 110px; } +.hero-content { position: relative; z-index: 2; display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(420px, .98fr); gap: clamp(38px, 5vw, 76px); align-items: center; min-height: 672px; } +.hero-copy { padding: 82px 0 110px; } .eyebrow, .section-label { color: #d2ae58; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; } -.hero h1 { margin: 18px 0 26px; max-width: 820px; font: 700 clamp(52px, 7.2vw, 94px)/.93 Tinos, 'Times New Roman', serif; letter-spacing: -.035em; } +.hero h1 { margin: 18px 0 26px; max-width: 680px; font: 700 clamp(48px, 5.2vw, 76px)/.94 Tinos, 'Times New Roman', serif; letter-spacing: -.035em; } .lede { max-width: 670px; color: #c4c0b4; font-size: 20px; line-height: 1.55; } .actions { display: flex; gap: 14px; margin-top: 38px; } .actions a { display: inline-block; padding: 14px 23px; text-decoration: none; font-weight: 700; } @@ -24,6 +25,10 @@ a { color: inherit; } .primary:hover { background: #e0b554; } .secondary { border: 1px solid #777264; color: #ded9cb; } .secondary:hover { border-color: #d2ae58; } +.gameplay-preview { position: relative; z-index: 1; margin: 28px 0 36px; padding: 7px; border: 1px solid rgba(224,188,98,.64); background: #1d1b15; box-shadow: 0 32px 80px rgba(0,0,0,.58), 0 0 0 1px rgba(0,0,0,.7); transform: perspective(1200px) rotateY(-2deg); transform-origin: right center; } +.gameplay-preview::before { content: ''; position: absolute; inset: 7px; z-index: 1; pointer-events: none; box-shadow: inset 0 0 0 1px rgba(255,255,255,.16); } +.gameplay-preview img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; object-position: center; } +.gameplay-preview figcaption { position: absolute; right: 15px; bottom: 15px; z-index: 2; padding: 7px 9px; background: rgba(7,8,6,.84); color: #ded7c6; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } .hero-grid { position: absolute; right: -130px; bottom: -170px; width: 670px; height: 670px; opacity: .2; transform: rotate(30deg) skewX(-15deg); background-image: linear-gradient(#d6b863 1px, transparent 1px), linear-gradient(90deg, #d6b863 1px, transparent 1px); background-size: 48px 48px; } main { width: min(1180px, calc(100% - 56px)); margin: auto; } @@ -43,9 +48,20 @@ main { width: min(1180px, calc(100% - 56px)); margin: auto; } .text-link { display: inline-block; margin-top: 18px; color: #dbb85f; font-weight: 700; text-decoration: none; } footer { display: flex; justify-content: space-between; gap: 24px; padding: 32px max(28px, calc((100vw - 1180px) / 2)); border-top: 1px solid #2a2b26; color: #77776f; font-size: 12px; } +@media (max-width: 1040px) { + .hero { min-height: auto; } + .hero-content { grid-template-columns: 1fr; gap: 0; padding-bottom: 72px; } + .hero-copy { max-width: 760px; padding: 82px 0 42px; } + .gameplay-preview { width: min(760px, 100%); margin: 0; transform: none; } +} + @media (max-width: 760px) { - .hero { min-height: 680px; } - .hero-copy { padding-top: 100px; } + .nav > div { gap: 18px; } + .hero-copy { padding-top: 68px; } + .hero h1 { font-size: clamp(44px, 13vw, 64px); } + .lede { font-size: 18px; } + .actions { flex-wrap: wrap; } + .gameplay-preview figcaption { position: static; display: block; padding: 10px 4px 3px; background: transparent; } .split { grid-template-columns: 1fr; gap: 34px; } .stats { grid-template-columns: 1fr 1fr; gap: 34px 0; } .stats div:nth-child(3) { padding-left: 0; border-left: 0; } diff --git a/src/utils/bugReportZip.js b/src/utils/bugReportZip.js new file mode 100644 index 0000000..01d43b2 --- /dev/null +++ b/src/utils/bugReportZip.js @@ -0,0 +1,126 @@ +/** + * Create a standards-compliant ZIP archive using the "stored" method. + * Bug reports are already dominated by a compressed PNG, so avoiding a + * runtime compression dependency keeps this static app small and portable. + */ + +const encoder = new TextEncoder(); + +let crcTable = null; + +function getCrcTable() { + if (crcTable) return crcTable; + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + crcTable[n] = c >>> 0; + } + return crcTable; +} + +export function crc32(bytes) { + const table = getCrcTable(); + let crc = 0xffffffff; + for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date) { + const year = Math.max(1980, date.getFullYear()); + return { + time: ((date.getHours() & 0x1f) << 11) | + ((date.getMinutes() & 0x3f) << 5) | + ((Math.floor(date.getSeconds() / 2)) & 0x1f), + date: (((year - 1980) & 0x7f) << 9) | + (((date.getMonth() + 1) & 0x0f) << 5) | + (date.getDate() & 0x1f), + }; +} + +function concat(parts) { + const length = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function bytesFor(value) { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return encoder.encode(String(value)); +} + +/** + * @param {{ name: string, data: string|Uint8Array|ArrayBuffer }[]} entries + * @param {Date} [createdAt] + * @returns {Uint8Array} + */ +export function createStoredZip(entries, createdAt = new Date()) { + const localParts = []; + const centralParts = []; + const { time, date } = dosDateTime(createdAt); + let localOffset = 0; + + for (const entry of entries) { + const name = encoder.encode(entry.name); + const data = bytesFor(entry.data); + const crc = crc32(data); + + const local = new Uint8Array(30); + const lv = new DataView(local.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(4, 20, true); + lv.setUint16(6, 0x0800, true); // UTF-8 names + lv.setUint16(8, 0, true); // stored, no compression + lv.setUint16(10, time, true); + lv.setUint16(12, date, true); + lv.setUint32(14, crc, true); + lv.setUint32(18, data.length, true); + lv.setUint32(22, data.length, true); + lv.setUint16(26, name.length, true); + lv.setUint16(28, 0, true); + localParts.push(local, name, data); + + const central = new Uint8Array(46); + const cv = new DataView(central.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(4, 20, true); + cv.setUint16(6, 20, true); + cv.setUint16(8, 0x0800, true); + cv.setUint16(10, 0, true); + cv.setUint16(12, time, true); + cv.setUint16(14, date, true); + cv.setUint32(16, crc, true); + cv.setUint32(20, data.length, true); + cv.setUint32(24, data.length, true); + cv.setUint16(28, name.length, true); + cv.setUint16(30, 0, true); + cv.setUint16(32, 0, true); + cv.setUint16(34, 0, true); + cv.setUint16(36, 0, true); + cv.setUint32(38, 0, true); + cv.setUint32(42, localOffset, true); + centralParts.push(central, name); + + localOffset += local.length + name.length + data.length; + } + + const localData = concat(localParts); + const centralData = concat(centralParts); + const end = new Uint8Array(22); + const ev = new DataView(end.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(4, 0, true); + ev.setUint16(6, 0, true); + ev.setUint16(8, entries.length, true); + ev.setUint16(10, entries.length, true); + ev.setUint32(12, centralData.length, true); + ev.setUint32(16, localData.length, true); + ev.setUint16(20, 0, true); + return concat([localData, centralData, end]); +} diff --git a/tests/game.spec.js b/tests/game.spec.js index e78cca5..7c0e3b2 100644 --- a/tests/game.spec.js +++ b/tests/game.spec.js @@ -276,6 +276,76 @@ test.describe('Original MGE graphics data', () => { expect(markers.missing).toBe(0); }); + test('city garrison flag uses its MGE marker and the active unit flashes over the city', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + await page.waitForFunction(() => window.__civ2?.mapScreen?._citySpriteData?.open?.[0]?.[0]); + + const result = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const gs = screen.gameState; + const founder = gs.units.find(u => u.civId === 0 && window.__civ2.UNITS[u.typeId]?.role === 5); + const city = gs.foundCity(founder); + const unit = gs._spawnUnit(2, 0, city.col, city.row); + gs.activeUnit = unit; + + const canvas = document.createElement('canvas'); + canvas.width = 1280; + canvas.height = 800; + const ctx = canvas.getContext('2d'); + const flagSprite = screen._civFlagSprites[gs.civs[city.civId].data.color]; + const unitSprite = screen._getColoredUnitSprite( + Math.floor(unit.typeId / 9), + unit.typeId % 9, + window.__civ2.CIV_COLORS?.[gs.civs[unit.civId].data.color] ?? '#fff', + ); + + const flagCalls = []; + const originalDrawImage = ctx.drawImage.bind(ctx); + ctx.drawImage = (...args) => { + if (args[0] === flagSprite) flagCalls.push(args.slice(1)); + return originalDrawImage(...args); + }; + screen._drawCity(ctx, city, 100, 200, true); + + let unitCalls = 0; + let selectionRingCalls = 0; + screen._drawSelectionRing = () => { selectionRingCalls++; }; + ctx.drawImage = (...args) => { + if (args[0] === unitSprite) unitCalls++; + return originalDrawImage(...args); + }; + screen._blinkTime = 0; + screen.render(ctx, canvas.width, canvas.height); + const visibleCalls = unitCalls; + screen._blinkTime = 200; + screen.render(ctx, canvas.width, canvas.height); + const hiddenCalls = unitCalls - visibleCalls; + + const { styleRow, sizeCol, hasWalls } = screen._getCitySpriteInfo(city); + const cell = screen._citySpriteData[hasWalls ? 'walled' : 'open'][styleRow][sizeCol]; + const destY = 200 - 64 / 2; + return { + flagCall: flagCalls.at(-1), + expectedFlag: [ + 100 + cell.flagLoc.x * 2, + destY + (cell.flagLoc.y - 17) * 2, + 28, + 44, + ], + visibleCalls, + hiddenCalls, + selectionRingCalls, + }; + }); + + expect(result.flagCall).toEqual(result.expectedFlag); + // The sidebar keeps its unit portrait visible; the one additional draw in + // the on-frame is the flashing map sprite over the city. + expect(result.visibleCalls).toBe(result.hiddenCalls + 1); + expect(result.selectionRingCalls).toBe(0); + }); + test('unit shields use original MGE light and dark player colours', async ({ page }) => { await gotoGame(page); await startTestGame(page); @@ -302,6 +372,64 @@ test.describe('Original MGE graphics data', () => { expect(colours).toContainEqual({ color: '#afafaf', type: 'back' }); }); + test('terrain joins use sparse MGE dither dots instead of replacing whole quadrants', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + + const opaqueCounts = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const masks = screen._buildDitherMasks(); + return masks.map(mask => { + const data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; + let opaque = 0; + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) opaque++; + return opaque; + }); + }); + + expect(opaqueCounts).toHaveLength(4); + for (const count of opaqueCounts) { + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(512); + } + }); + + test('fog edges use sparse MGE dither dots instead of opaque quadrants', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + + const masks = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const gs = screen.gameState; + for (const row of gs._visibility) row.fill(0); + gs._visibility[5][5] = 1; + + screen._showHiddenTerrain = true; + screen._fogDitherQuads = null; + const canvas = document.createElement('canvas'); + canvas.width = 128; canvas.height = 64; + screen._drawFogDither(canvas.getContext('2d'), 0, 0, 5, 5); + const revealBuiltFog = screen._fogDitherQuads !== null; + + screen._showHiddenTerrain = false; + screen._drawFogDither(canvas.getContext('2d'), 0, 0, 5, 5); + const quads = screen._fogDitherQuads.map(({ canvas: mask }) => { + const data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; + let opaque = 0; + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) opaque++; + return { opaque, pixels: mask.width * mask.height }; + }); + return { revealBuiltFog, quads }; + }); + + expect(masks.revealBuiltFog).toBe(false); + expect(masks.quads).toHaveLength(4); + for (const mask of masks.quads) { + expect(mask.opaque).toBeGreaterThan(0); + expect(mask.opaque).toBeLessThan(mask.pixels); + } + }); + test('city sprite selection follows era, size, capital, walls, and Great Wall', async ({ page }) => { await gotoGame(page); await startTestGame(page); @@ -423,10 +551,17 @@ test.describe('Original MGE graphics data', () => { screen._cityScreenProdList = true; const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 800; - screen._drawCityScreen(canvas.getContext('2d'), 1280, 800); + const ctx = canvas.getContext('2d'); + screen._drawCityScreen(ctx, 1280, 800); + const firstRow = screen._cityScreenItemRects[0]; + const lastRow = screen._cityScreenItemRects.at(-1); + const pixel = (x, y) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data]; return { dialog: screen._cityProductionDialogRect, itemCount: screen._cityScreenItemRects.length, + itemNames: screen._cityScreenItemRects.map(rect => rect.item.name), + selectedFill: pixel(firstRow.x + 55, firstRow.y + firstRow.h / 2), + emptyListFill: pixel(firstRow.x + 55, lastRow.y + lastRow.h + 10), tabs: screen._cityScreenTabRects.length, queue: screen._cityScreenQueueModeRect, auto: screen._cityScreenAutoRect, @@ -435,8 +570,12 @@ test.describe('Original MGE graphics data', () => { }; }); - expect(result.dialog.w).toBe(550); // 440px Game.txt dialog at city window's 1.25 scale + expect(result.dialog.w).toBe(680); // original MGE top-level dialog width at this viewport + expect(result.dialog.h).toBe(267.5); expect(result.itemCount).toBeGreaterThan(0); + expect(result.itemNames).toContain('Settlers'); + expect(result.selectedFill).toEqual([128, 128, 128, 255]); + expect(result.emptyListFill).toEqual([192, 192, 192, 255]); expect(result.tabs).toBe(0); expect(result.queue).toBeNull(); expect(result.auto).not.toBeNull(); @@ -444,6 +583,47 @@ test.describe('Original MGE graphics data', () => { expect(result.ok).not.toBeNull(); }); + test('size-one city holds completed Settlers until it grows', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + + const result = await page.evaluate(() => { + const gs = window.__civ2.mapScreen.gameState; + const founder = gs.units.find(u => u.civId === 0 && window.__civ2.UNITS[u.typeId]?.role === 5); + const city = gs.foundCity(founder); + city.production = { type: 'unit', id: 0 }; + city.shields = gs._productionCost(city.production); + const beforeUnits = gs.units.filter(u => u.civId === 0 && u.typeId === 0).length; + + const completedAtSizeOne = gs._completeProduction(city); + const held = { + shields: city.shields, + production: city.production, + units: gs.units.filter(u => u.civId === 0 && u.typeId === 0).length, + }; + + city.size = 2; + const completedAtSizeTwo = gs._completeProduction(city); + return { + cost: gs._productionCost({ type: 'unit', id: 0 }), + beforeUnits, + completedAtSizeOne, + held, + completedAtSizeTwo, + finalSize: city.size, + finalUnits: gs.units.filter(u => u.civId === 0 && u.typeId === 0).length, + }; + }); + + expect(result.completedAtSizeOne).toBe(false); + expect(result.held.shields).toBe(result.cost); + expect(result.held.production).toEqual({ type: 'unit', id: 0 }); + expect(result.held.units).toBe(result.beforeUnits); + expect(result.completedAtSizeTwo).toBe(true); + expect(result.finalSize).toBe(1); + expect(result.finalUnits).toBe(result.beforeUnits + 1); + }); + test('production list highlights a row and changes production only after OK', async ({ page }) => { await gotoGame(page); await startTestGame(page); @@ -571,6 +751,77 @@ test.describe('Original MGE graphics data', () => { expect(geom.mapY).toBe(105); }); + test('minimap paints ocean blue and land green', async ({ page }) => { + await gotoGame(page); + await startTestGame(page, { mapCols: 80, mapRows: 50 }); + + const colours = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const gs = screen.gameState; + gs.tiles[0][0] = window.__civ2.TERRAIN.OCEAN; + gs.tiles[0][1] = window.__civ2.TERRAIN.GRASSLAND; + gs._visibility[0][0] = 2; + gs._visibility[0][1] = 2; + + const canvas = document.createElement('canvas'); + canvas.width = 1280; + canvas.height = 800; + const ctx = canvas.getContext('2d'); + screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs); + const { mapX, mapY, mmTileW } = screen._mmGeom(canvas.width, canvas.height); + return { + ocean: Array.from(ctx.getImageData(mapX, mapY, 1, 1).data.slice(0, 3)), + land: Array.from(ctx.getImageData(mapX + mmTileW, mapY, 1, 1).data.slice(0, 3)), + }; + }); + + expect(colours.ocean).toEqual([0, 0, 95]); + expect(colours.land).toEqual([55, 123, 23]); + }); + + test('minimap reveal mode shows every tile without fog', async ({ page }) => { + await gotoGame(page); + await startTestGame(page, { mapCols: 40, mapRows: 25 }); + + const result = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const gs = screen.gameState; + for (let row = 0; row < gs.mapRows; row++) { + gs._visibility[row].fill(0); + for (let col = 0; col < gs.mapCols; col++) { + gs.tiles[row][col] = (row + col) % 2 + ? window.__civ2.TERRAIN.OCEAN + : window.__civ2.TERRAIN.GRASSLAND; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = 1280; canvas.height = 800; + const ctx = canvas.getContext('2d'); + const geom = screen._mmGeom(canvas.width, canvas.height); + + screen._showHiddenTerrain = false; + screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs); + const fogged = Array.from(ctx.getImageData(geom.mapX, geom.mapY, 1, 1).data.slice(0, 3)); + + screen._showHiddenTerrain = true; + screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs); + let blackTiles = 0; + for (let row = 0; row < gs.mapRows; row++) { + for (let col = 0; col < gs.mapCols; col++) { + const x = geom.mapX + col * geom.mmTileW + (row % 2 ? Math.floor(geom.mmTileW / 2) : 0); + const y = geom.mapY + row * geom.mmTileH; + const pixel = ctx.getImageData(x, y, 1, 1).data; + if (pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0) blackTiles++; + } + } + return { fogged, blackTiles }; + }); + + expect(result.fogged).toEqual([0, 0, 0]); + expect(result.blackTiles).toBe(0); + }); + test('sidebar uses light shield colour and original AI turn marker size', async ({ page }) => { await gotoGame(page); await startTestGame(page); @@ -1154,6 +1405,162 @@ test.describe('Save / Load', () => { }); }); +// ─── 6a. Bug reports ───────────────────────────────────────────────────────── + +test.describe('Bug reports', () => { + test.beforeEach(async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + }); + + test('Game menu report contains screenshot, restorable state, and view diagnostics', async ({ page }) => { + const result = await page.evaluate(async () => { + const screen = window.__civ2.mapScreen; + const canvas = document.getElementById('game-canvas'); + + // Render the Game dropdown to prove the feature is exposed as a genuine + // menu item, then close it before capturing the underlying game scene. + screen._openMenu = 0; + screen.render(canvas.getContext('2d'), canvas.width, canvas.height); + const menuItem = screen._menuItemRects.find(item => item.action === 'game_reportbug'); + screen._openMenu = null; + screen._menuItemRects = []; + + screen.viewX = 96; + screen.viewY = 48; + screen._zoomLevel = 2; + const stateBefore = screen.gameState.toSaveData(); + + let reportDrawsDuringCapture = 0; + const originalDraw = screen._drawBugReportDialog; + screen._drawBugReportDialog = function(...args) { + reportDrawsDuringCapture++; + return originalDraw.apply(this, args); + }; + screen._executeMenuAction(menuItem.action); + const drawsBeforeNextTask = reportDrawsDuringCapture; + const file = await screen._bugReportDialog.promise; + screen._drawBugReportDialog = originalDraw; + + const bytes = new Uint8Array(await file.arrayBuffer()); + const entries = {}; + const decoder = new TextDecoder(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 0; + while (offset + 30 <= bytes.length && view.getUint32(offset, true) === 0x04034b50) { + const compressedSize = view.getUint32(offset + 18, true); + const nameLength = view.getUint16(offset + 26, true); + const extraLength = view.getUint16(offset + 28, true); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const name = decoder.decode(bytes.slice(nameStart, nameStart + nameLength)); + entries[name] = bytes.slice(dataStart, dataStart + compressedSize); + offset = dataStart + compressedSize; + } + + const savedState = JSON.parse(decoder.decode(entries['game-state.json'])); + const report = JSON.parse(decoder.decode(entries['report.json'])); + const restored = screen.gameState.constructor.fromSaveData(savedState); + const png = entries['screenshot.png']; + const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' })); + const archiveEnd = Array.from(bytes.slice(-22, -18)); + const answer = { + menuItem: !!menuItem, + status: screen._bugReportDialog.status, + filename: file.name, + mime: file.type, + zipStart: Array.from(bytes.slice(0, 4)), + zipEnd: archiveEnd, + entryNames: Object.keys(entries).sort(), + pngSignature: Array.from(png.slice(0, 8)), + pngSize: [bitmap.width, bitmap.height], + stateMatches: JSON.stringify(savedState) === JSON.stringify(stateBefore), + restoredTurn: restored.turn, + capturedTurn: stateBefore.turn, + rendererState: report.rendererState, + reportFormat: report.format, + drawsBeforeNextTask, + }; + bitmap.close(); + return answer; + }); + + expect(result.menuItem).toBe(true); + expect(result.status).toBe('ready'); + expect(result.filename).toMatch(/^civ2-bug-report-.*\.zip$/); + expect(result.mime).toBe('application/zip'); + expect(result.zipStart).toEqual([0x50, 0x4b, 0x03, 0x04]); + expect(result.zipEnd).toEqual([0x50, 0x4b, 0x05, 0x06]); + expect(result.entryNames).toEqual(['README.txt', 'game-state.json', 'report.json', 'screenshot.png']); + expect(result.pngSignature).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(result.pngSize).toEqual([1280, 800]); + expect(result.stateMatches).toBe(true); + expect(result.restoredTurn).toBe(result.capturedTurn); + expect(result.rendererState).toMatchObject({ viewX: 96, viewY: 48, zoomLevel: 2 }); + expect(result.reportFormat).toBe('civ2-web-bug-report'); + expect(result.drawsBeforeNextTask, 'report UI must not appear inside its own screenshot').toBe(0); + }); + + test('Share / Email passes the ZIP to the system share sheet and GitHub opens a prepared issue', async ({ page }) => { + const result = await page.evaluate(async () => { + const screen = window.__civ2.mapScreen; + screen._openBugReportDialog(); + await screen._bugReportDialog.promise; + + const downloaded = []; + const openedUrls = []; + const originalClick = HTMLAnchorElement.prototype.click; + const originalOpen = window.open; + HTMLAnchorElement.prototype.click = function() { downloaded.push(this.download); }; + window.open = url => { openedUrls.push(String(url)); return null; }; + Object.defineProperty(navigator, 'canShare', { + configurable: true, + value: data => data.files?.length === 1, + }); + Object.defineProperty(navigator, 'share', { + configurable: true, + value: async data => { + window.__sharedBugReport = { + title: data.title, + text: data.text, + names: data.files.map(file => file.name), + types: data.files.map(file => file.type), + }; + }, + }); + + const shared = await screen._shareBugReport(); + Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => false }); + const emailFallback = await screen._shareBugReport(); + const opened = screen._openGitHubBugReport(); + const answer = { + shared, + emailFallback, + opened, + shareData: window.__sharedBugReport, + emailUrl: openedUrls[0], + issueUrl: openedUrls[1], + downloaded, + }; + HTMLAnchorElement.prototype.click = originalClick; + window.open = originalOpen; + return answer; + }); + + expect(result.shared).toBe(true); + expect(result.emailFallback).toBe(false); + expect(result.opened).toBe(true); + expect(result.shareData.title).toBe('Civilization II bug report'); + expect(result.shareData.names[0]).toMatch(/^civ2-bug-report-.*\.zip$/); + expect(result.shareData.types).toEqual(['application/zip']); + expect(result.downloaded).toEqual([result.shareData.names[0], result.shareData.names[0]]); + expect(result.emailUrl).toMatch(/^mailto:\?subject=/); + expect(decodeURIComponent(result.emailUrl)).toContain(`File: ${result.shareData.names[0]}`); + expect(result.issueUrl).toContain('https://github.com/wan0net/civ2/issues/new'); + expect(decodeURIComponent(result.issueUrl)).toContain(`attach the downloaded \`${result.shareData.names[0]}\``); + }); +}); + // ─── 7. City Interaction ────────────────────────────────────────────────────── test.describe('City Interaction', () => { @@ -1581,6 +1988,35 @@ test.describe('Research', () => { expect(completed.granted).toBe(true); }); + test('fog of war preserves explored tiles while revealing only the current unit area', async ({ page }) => { + const result = await page.evaluate(() => { + const gs = window.__civ2.mapScreen.gameState; + for (const row of gs._visibility) row.fill(0); + gs.cities = []; + gs.units = [{ id: 999, civId: 0, typeId: 0, col: 5, row: 5 }]; + + gs._updateVisibility(); + const first = { + unit: gs._visibility[5][5], + neighbour: gs._visibility[5][6], + distant: gs._visibility[20][30], + }; + + gs.units[0].col = 15; + gs.units[0].row = 15; + gs._updateVisibility(); + const moved = { + oldUnit: gs._visibility[5][5], + newUnit: gs._visibility[15][15], + distant: gs._visibility[20][30], + }; + return { first, moved }; + }); + + expect(result.first).toEqual({ unit: 2, neighbour: 2, distant: 0 }); + expect(result.moved).toEqual({ oldUnit: 1, newUnit: 2, distant: 0 }); + }); + test('research chooser matches the compact Game.txt listbox', async ({ page }) => { const result = await page.evaluate(() => { const screen = window.__civ2.mapScreen; @@ -1588,15 +2024,26 @@ test.describe('Research', () => { screen._researchGoalCandidates = null; const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 800; - screen._drawResearchChooser(canvas.getContext('2d'), 1280, 800); + const ctx = canvas.getContext('2d'); + const titleCalls = []; + const fillText = ctx.fillText.bind(ctx); + ctx.fillText = (text, x, y, ...args) => { + if (String(text).startsWith('What discovery shall our ')) { + titleCalls.push({ text, width: ctx.measureText(text).width, font: ctx.font }); + } + return fillText(text, x, y, ...args); + }; + screen._drawResearchChooser(ctx, 1280, 800); return { dialog: screen._researchChooserRect, + title: titleCalls[0], specials: screen._researchChooserRects.filter(r => r.advId < 0).map(r => r.advId), rows: screen._researchChooserRects.filter(r => r.advId >= 0).length, }; }); - expect(result.dialog.w).toBe(300); + expect(result.dialog.w).toBeGreaterThanOrEqual(300); + expect(result.title.width).toBeLessThanOrEqual(result.dialog.w - 18); expect(result.specials).toEqual([-3, -2, -1]); // Help, Goal, implicit standard OK expect(result.rows).toBeGreaterThan(0); expect(result.rows).toBeLessThanOrEqual(16); diff --git a/tests/site.spec.js b/tests/site.spec.js index c3cce5d..eb5a3d3 100644 --- a/tests/site.spec.js +++ b/tests/site.spec.js @@ -6,6 +6,30 @@ test.describe('Public project site', () => { await expect(page.getByRole('heading', { name: /Build a civilization/i })).toBeVisible(); await expect(page.getByText(/must own Civilization II MGE/i)).toBeVisible(); await expect(page.getByRole('link', { name: 'Open the game' })).toHaveAttribute('href', './game.html'); + + const preview = page.getByRole('img', { name: /Rome and three Roman warrior units/i }); + await expect(preview).toBeVisible(); + await expect(preview).toHaveAttribute('src', './site/gameplay.jpg'); + + const [headingBox, previewBox] = await Promise.all([ + page.getByRole('heading', { name: /Build a civilization/i }).boundingBox(), + preview.boundingBox(), + ]); + expect(headingBox).not.toBeNull(); + expect(previewBox).not.toBeNull(); + expect(previewBox.x).toBeGreaterThan(headingBox.x); + expect(previewBox.width).toBeGreaterThan(400); + }); + + test('gameplay preview remains visible on a narrow screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + const preview = page.getByRole('img', { name: /Rome and three Roman warrior units/i }); + await expect(preview).toBeVisible(); + const previewBox = await preview.boundingBox(); + expect(previewBox.width).toBeGreaterThan(300); + expect(previewBox.x).toBeGreaterThanOrEqual(0); }); test('game does not load MGE assets before ownership attestation', async ({ page }) => {