472 lines
20 KiB
JavaScript
472 lines
20 KiB
JavaScript
// Master of Vega — the Research screen.
|
|
//
|
|
// Left: a 2:3 ambient loop of the viewing empire's own species at work (or a
|
|
// letterboxed portrait fallback until that clip exists). Right, top: a 2x3
|
|
// grid of tech-field boxes — name, a lockable allocation slider, and the
|
|
// current target's name/description/progress. Right, bottom: once a box is
|
|
// clicked, that field's full tech ladder (researched / available / locked to
|
|
// this species) in a much larger, scrollable, tooltip-enabled list than the
|
|
// old single-shell version ever had room for.
|
|
//
|
|
// Split out of VegaScreens.js once this screen grew past the shared
|
|
// modalShell+slider factory-function pattern those simpler screens still
|
|
// use — the same jump in complexity that pulled VegaAudience.js and
|
|
// VegaColonyIntro.js out earlier. FONT/D/modalShell/slider are borrowed back
|
|
// from there, same as those two files do.
|
|
|
|
import * as Phaser from 'phaser';
|
|
import { FONT, D, modalShell, slider } from './VegaScreens.js';
|
|
import { techCost, techCostFactor } from './VegaRules.js';
|
|
import {
|
|
setResearchAlloc, nextResearchTarget, setResearchTarget, canResearch, fieldTechLevel,
|
|
} from './VegaLogic.js';
|
|
import {
|
|
makeSpeciesPortrait, sourceWidth, speciesResearchVideoKey, hasSpeciesResearchVideo,
|
|
} from './VegaArt.js';
|
|
import { Tooltip } from '../../ui/Tooltip.js';
|
|
import { describeTechTooltip } from './VegaTooltips.js';
|
|
|
|
const ACCENT = 0x6fc4ff;
|
|
const PANEL = 0x0b1220;
|
|
|
|
/** Source resolution the research clips are recorded at — 2:3 portrait. */
|
|
const RESEARCH_VIDEO_SRC_W = 640;
|
|
|
|
function researchVideoPath(scene, speciesId) {
|
|
return scene.cache.json.get('mastervega-artwork')?.researchVideos?.[speciesId]?.path ?? null;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// JIT loading — one-for-one with VegaAudience.js's warmBytes/
|
|
// ensureOneAudienceVideo pair, minus the mood dimension: Research only ever
|
|
// shows the VIEWING empire's own species, one clip, so there is nothing to
|
|
// warm speculatively and nothing to swap between.
|
|
|
|
const warmed = new WeakMap();
|
|
function warmBytes(scene, key, path) {
|
|
let store = warmed.get(scene);
|
|
if (!store) { store = new Map(); warmed.set(scene, store); }
|
|
if (store.has(key)) return;
|
|
try {
|
|
const el = document.createElement('video');
|
|
el.preload = 'auto';
|
|
el.muted = true;
|
|
el.setAttribute('playsinline', 'playsinline');
|
|
el.src = path;
|
|
el.load();
|
|
store.set(key, el);
|
|
} catch (err) { /* prefetch is an optimisation, never a requirement */ }
|
|
}
|
|
|
|
const inFlight = new WeakMap();
|
|
function pendingFor(scene) {
|
|
let set = inFlight.get(scene);
|
|
if (!set) { set = new Set(); inFlight.set(scene, set); }
|
|
return set;
|
|
}
|
|
|
|
function ensureOneResearchVideo(scene, speciesId, onDone) {
|
|
const key = speciesResearchVideoKey(speciesId);
|
|
const path = researchVideoPath(scene, speciesId);
|
|
if (path) warmBytes(scene, key, path);
|
|
if (hasSpeciesResearchVideo(scene, speciesId) || !path) { onDone?.(); return; }
|
|
|
|
const pending = pendingFor(scene);
|
|
const doneEvent = `filecomplete-video-${key}`;
|
|
if (onDone) {
|
|
let onLoad;
|
|
let onError;
|
|
const settle = () => {
|
|
scene.load.off(doneEvent, onLoad);
|
|
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
|
|
onDone();
|
|
};
|
|
onLoad = () => settle();
|
|
onError = (file) => { if (file?.key === key) settle(); };
|
|
scene.load.once(doneEvent, onLoad);
|
|
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
|
|
}
|
|
if (pending.has(key)) return;
|
|
pending.add(key);
|
|
let clearDone;
|
|
let clearError;
|
|
const clear = () => {
|
|
scene.load.off(doneEvent, clearDone);
|
|
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
|
|
pending.delete(key);
|
|
};
|
|
clearDone = () => clear();
|
|
clearError = (file) => { if (file?.key === key) clear(); };
|
|
scene.load.once(doneEvent, clearDone);
|
|
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
|
|
scene.load.video(key, path, true); // noAudio: true — ambient loop, silent
|
|
if (!scene.load.isLoading()) scene.load.start();
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// The species video panel. Letterboxed by WIDTH per this game's universal
|
|
// video convention (never setDisplaySize, never crop) — a square portrait
|
|
// centred inside the taller 2:3 frame until a real research clip loads, then
|
|
// the clip itself scaled the same way.
|
|
|
|
export function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) {
|
|
const container = scene.add.container(x, y);
|
|
|
|
function showFallback() {
|
|
container.removeAll(true);
|
|
const spec = rules.species[speciesId];
|
|
const barColor = Phaser.Display.Color.HexStringToColor(spec.color).color;
|
|
container.add(scene.add.rectangle(w / 2, h / 2, w, h, barColor, 0.18));
|
|
container.add(makeSpeciesPortrait(scene, rules, art, speciesId, w / 2, h / 2, w));
|
|
}
|
|
|
|
function showVideo() {
|
|
container.removeAll(true);
|
|
const v = scene.add.video(w / 2, h / 2, speciesResearchVideoKey(speciesId));
|
|
v.setMute(true);
|
|
v.setLoop(true);
|
|
// A fresh Video reports a placeholder width (not 0) until its first frame
|
|
// decodes — sourceWidth() is the documented guard for that trap, so the
|
|
// fit is re-applied once the real texture exists rather than only once.
|
|
const fit = () => v.setScale(w / sourceWidth(v, RESEARCH_VIDEO_SRC_W));
|
|
fit();
|
|
v.on('created', fit);
|
|
v.on('playing', fit);
|
|
v.play(true);
|
|
v.once('error', () => { if (v.scene) showFallback(); });
|
|
container.add(v);
|
|
}
|
|
|
|
showFallback();
|
|
ensureOneResearchVideo(scene, speciesId, () => {
|
|
if (!container.scene) return; // screen closed before the fetch landed
|
|
if (hasSpeciesResearchVideo(scene, speciesId)) showVideo();
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
// `initialField`: which category the screen opens on, e.g. the "View
|
|
// Research" button on a turn report's Research row jumps straight to the
|
|
// field that just completed instead of always landing on the first one.
|
|
export function openResearchScreen(scene, rules, state, e, art, onClose, { initialField = null } = {}) {
|
|
const emp = state.empires[e];
|
|
// A game saved before the research-lock padlock existed has no
|
|
// allocLocked at all — VegaLogic.deserialize() back-fills this for the
|
|
// normal load path, but guard here too rather than trust every caller.
|
|
emp.allocLocked ??= {};
|
|
const spec = rules.species[emp.speciesId];
|
|
const fields = rules.techFieldList;
|
|
|
|
const tooltip = new Tooltip(scene, { depth: D.modal + 5 });
|
|
|
|
const shell = modalShell(scene, 'Research', () => {
|
|
tooltip.destroy();
|
|
maskG?.destroy();
|
|
onClose?.();
|
|
}, { width: 1500, height: 880 });
|
|
|
|
// --- layout -------------------------------------------------------------
|
|
const videoH = shell.body.h;
|
|
const videoW = Math.round(videoH * (2 / 3));
|
|
const rightX = shell.body.x + videoW + 24;
|
|
const rightW = shell.body.w - videoW - 24;
|
|
|
|
const GAP = 16;
|
|
const boxW = (rightW - 2 * GAP) / 3;
|
|
const boxH = 200;
|
|
const gridY = shell.body.y;
|
|
const gridBottom = gridY + 2 * boxH + GAP;
|
|
const headingY = gridBottom + 6;
|
|
const detailY = gridBottom + 34;
|
|
const detailH = shell.body.h - (detailY - shell.body.y);
|
|
const detailX = rightX;
|
|
const detailW = rightW;
|
|
|
|
shell.add(buildVideoPanel(scene, rules, art, emp.speciesId, shell.body.x, shell.body.y, videoW, videoH));
|
|
|
|
let selectedField = (initialField && rules.techFields[initialField]) ? initialField : fields[0].id;
|
|
let gridLayer = null;
|
|
let detailLayer = null;
|
|
let maskG = null;
|
|
const sliders = []; // [{fieldId, slider}], rebuilt with the grid
|
|
|
|
// --- category grid --------------------------------------------------
|
|
function buildCategoryBox(field, i) {
|
|
const row = Math.floor(i / 3);
|
|
const col = i % 3;
|
|
const bx = rightX + col * (boxW + GAP);
|
|
const by = gridY + row * (boxH + GAP);
|
|
const selected = field.id === selectedField;
|
|
|
|
// The background is the field-select click zone, added FIRST so the
|
|
// padlock and slider (added after) win Phaser's default topOnly input
|
|
// priority over their own smaller areas — same add-order mechanism
|
|
// VegaColonyView.js already relies on for its nested lock box + slider.
|
|
const bg = scene.add.rectangle(bx + boxW / 2, by + boxH / 2, boxW, boxH, PANEL, selected ? 0.85 : 0.5)
|
|
.setStrokeStyle(selected ? 2.5 : 1, ACCENT, selected ? 0.95 : 0.35)
|
|
.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerup', () => {
|
|
if (selectedField === field.id) return;
|
|
selectedField = field.id;
|
|
buildGrid();
|
|
buildDetail();
|
|
});
|
|
gridLayer.add(bg);
|
|
|
|
const lvl = fieldTechLevel(rules, state, e, field.id);
|
|
const fieldIconSize = 26;
|
|
gridLayer.add(scene.add.image(bx + 12 + fieldIconSize / 2, by + 8 + 10, art.techfields, field.iconFrame)
|
|
.setDisplaySize(fieldIconSize, fieldIconSize));
|
|
gridLayer.add(scene.add.text(bx + 12 + fieldIconSize + 8, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, {
|
|
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
|
|
}));
|
|
|
|
// Padlock — VegaColonyView.js:323-349's exact semantics, transposed onto
|
|
// emp.allocLocked: can't lock the last unlocked field, since
|
|
// setResearchAlloc would have nothing left to renormalise into.
|
|
const locked = !!emp.allocLocked[field.id];
|
|
const lockedCount = fields.filter((f) => emp.allocLocked[f.id]).length;
|
|
const lockable = locked || lockedCount < fields.length - 1;
|
|
const lockBox = scene.add.rectangle(bx + boxW - 22, by + 20, 22, 22,
|
|
locked ? 0x2a3550 : 0x16253c).setStrokeStyle(1, ACCENT, lockable ? 0.55 : 0.18);
|
|
gridLayer.add(lockBox);
|
|
gridLayer.add(scene.add.text(bx + boxW - 22, by + 19, locked ? '■' : '□', {
|
|
fontFamily: FONT, fontSize: '14px', color: locked ? '#ffd88a' : (lockable ? '#8fa8c0' : '#3c4c60'),
|
|
}).setOrigin(0.5));
|
|
if (lockable) {
|
|
lockBox.setInteractive({ useHandCursor: true });
|
|
lockBox.on('pointerup', () => {
|
|
if (locked) delete emp.allocLocked[field.id];
|
|
else emp.allocLocked[field.id] = true;
|
|
buildGrid();
|
|
});
|
|
}
|
|
|
|
const s = slider(scene, bx + 14, by + 42, boxW - 28, 'Alloc', emp.alloc[field.id] ?? 0, (v) => {
|
|
setResearchAlloc(rules, state, e, field.id, v);
|
|
// Every field's share shifts, so redraw them all — INCLUDING this one,
|
|
// which may have been clipped by the room locked fields left (same
|
|
// trap VegaColonyView.js's identical callback calls out). Only the
|
|
// slider widgets move here; the progress/target text below them is
|
|
// deliberately left alone until the next full rebuild (lock toggle or
|
|
// box click), matching how the old single-shell screen already
|
|
// avoided rebuilding on every drag tick.
|
|
sliders.forEach(({ fieldId, slider: other }) => other.setValue(emp.alloc[fieldId] ?? 0));
|
|
});
|
|
gridLayer.add(s.container);
|
|
sliders.push({ fieldId: field.id, slider: s });
|
|
|
|
let y = by + 96;
|
|
const targetId = emp.researching[field.id] ?? nextResearchTarget(rules, state, e, field.id);
|
|
if (targetId) {
|
|
const tech = rules.techs[targetId];
|
|
const cost = techCost(rules, tech, emp.knownInField[field.id], techCostFactor(spec, field.id));
|
|
const have = emp.beakers[field.id] ?? 0;
|
|
const nameT = scene.add.text(bx + 14, y, tech.name, {
|
|
fontFamily: FONT, fontSize: '15px', color: '#ffd88a', wordWrap: { width: boxW - 28 },
|
|
});
|
|
gridLayer.add(nameT);
|
|
y += nameT.height + 3;
|
|
const descT = scene.add.text(bx + 14, y, tech.desc, {
|
|
fontFamily: FONT, fontSize: '12px', color: '#8fa8c0', wordWrap: { width: boxW - 28 },
|
|
});
|
|
gridLayer.add(descT);
|
|
y += descT.height + 6;
|
|
const barW = boxW - 28;
|
|
gridLayer.add(scene.add.rectangle(bx + 14, y, barW, 6, 0x1b2b42).setOrigin(0, 0));
|
|
gridLayer.add(scene.add.rectangle(bx + 14, y, barW * Phaser.Math.Clamp(have / cost, 0, 1), 6, 0xffd88a)
|
|
.setOrigin(0, 0));
|
|
y += 12;
|
|
gridLayer.add(scene.add.text(bx + 14, y, `${Math.round(have)} / ${cost} RP`, {
|
|
fontFamily: FONT, fontSize: '12px', color: '#7f97b3',
|
|
}));
|
|
} else {
|
|
gridLayer.add(scene.add.text(bx + 14, y, 'Nothing further available', {
|
|
fontFamily: FONT, fontSize: '13px', color: '#6b7f96', wordWrap: { width: boxW - 28 },
|
|
}));
|
|
}
|
|
}
|
|
|
|
function buildGrid() {
|
|
gridLayer?.destroy();
|
|
gridLayer = scene.add.container(0, 0);
|
|
shell.add(gridLayer);
|
|
sliders.length = 0;
|
|
fields.forEach((field, i) => buildCategoryBox(field, i));
|
|
}
|
|
|
|
// --- detail pane: the selected field's tech TREE — a ten-tier spine with
|
|
// up to two short branch spurs (every branch tech added this session is a
|
|
// dead end, never reconverging — confirmed against the data), rendered as
|
|
// small status badges connected by prereq lines rather than a text list,
|
|
// so the shape of the field is visible at a glance. Full name/cost/desc
|
|
// stays in the hover tooltip, unchanged. Masked+scrollable defensively
|
|
// (mirrors VegaAudience.js's chat-log masked-scroll shape) for a future
|
|
// field that outgrows 10 tiers or a 3+-way branch — today's data always
|
|
// fits the pane with no scrolling needed.
|
|
function buildDetail() {
|
|
detailLayer?.destroy();
|
|
maskG?.destroy();
|
|
detailLayer = scene.add.container(0, 0);
|
|
shell.add(detailLayer);
|
|
|
|
const field = rules.techFields[selectedField];
|
|
const detailLvl = fieldTechLevel(rules, state, e, selectedField);
|
|
const detailIconSize = 22;
|
|
detailLayer.add(scene.add.image(detailX + detailIconSize / 2, headingY + 9, art.techfields, field.iconFrame)
|
|
.setDisplaySize(detailIconSize, detailIconSize));
|
|
detailLayer.add(scene.add.text(detailX + detailIconSize + 8, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, {
|
|
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
|
|
}));
|
|
|
|
const bg = scene.add.rectangle(detailX + detailW / 2, detailY + detailH / 2, detailW, detailH, 0x000000, 0.35)
|
|
.setStrokeStyle(1, ACCENT, 0.3).setInteractive();
|
|
detailLayer.add(bg);
|
|
|
|
const tree = scene.add.container(0, 0);
|
|
detailLayer.add(tree);
|
|
maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
|
maskG.fillStyle(0xffffff);
|
|
maskG.fillRect(detailX, detailY, detailW, detailH);
|
|
tree.setMask(maskG.createGeometryMask());
|
|
|
|
// --- layout: rung index -> column x, lane within a rung -> row y.
|
|
const PAD = 28;
|
|
const LABEL_H = 20;
|
|
const NODE = 34;
|
|
const LANE_STEP = 78;
|
|
const innerX0 = detailX + PAD;
|
|
const innerX1 = detailX + detailW - PAD;
|
|
const innerY0 = detailY + PAD;
|
|
const innerY1 = detailY + detailH - PAD;
|
|
const spineY = innerY0 + LABEL_H + (detailH - 2 * PAD - LABEL_H) / 2;
|
|
|
|
// Node CENTRES are placed NODE/2 in from innerX0/innerX1, so the node's
|
|
// own edge lands on the padding boundary instead of overshooting past it
|
|
// — colStep spans the gap between node edges, not between the raw pane
|
|
// edges.
|
|
const usableX0 = innerX0 + NODE / 2;
|
|
const usableX1 = innerX1 - NODE / 2;
|
|
const rungs = rules.techRungsByField[selectedField];
|
|
const colStep = (usableX1 - usableX0) / Math.max(1, rungs.length - 1);
|
|
const nodeX = (i) => usableX0 + i * colStep;
|
|
const nodeY = (lane) => {
|
|
if (lane === 0) return spineY;
|
|
const sign = lane % 2 === 1 ? -1 : 1;
|
|
return spineY + sign * Math.ceil(lane / 2) * LANE_STEP;
|
|
};
|
|
|
|
const pos = new Map(); // techId -> {x, y}
|
|
rungs.forEach((rung, i) => {
|
|
rung.techs.forEach((tech, lane) => pos.set(tech.id, { x: nodeX(i), y: nodeY(lane) }));
|
|
});
|
|
|
|
rungs.forEach((rung, i) => {
|
|
tree.add(scene.add.text(nodeX(i), innerY0, `T${rung.tier}`, {
|
|
fontFamily: FONT, fontSize: '12px', color: '#5a708c',
|
|
}).setOrigin(0.5, 0));
|
|
});
|
|
|
|
// The field's current research target — same lookup the category box
|
|
// uses for its own progress bar — gets the same yellow as that bar.
|
|
const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField);
|
|
const statusColour = (tech) => {
|
|
if (tech.id === targetId) return '#ffd88a';
|
|
if (emp.known[tech.id]) return '#7fd8a0';
|
|
return emp.available[tech.id] ? '#9fb6cc' : '#5a4450';
|
|
};
|
|
|
|
// Edges first, so the badge nodes render on top of the lines feeding them.
|
|
const edges = scene.add.graphics();
|
|
tree.add(edges);
|
|
for (const tech of rules.techsByField[selectedField]) {
|
|
const prereqId = tech.prereqs[0];
|
|
if (!prereqId) continue;
|
|
const from = pos.get(prereqId);
|
|
const to = pos.get(tech.id);
|
|
const isCurrent = tech.id === targetId;
|
|
const colourInt = Phaser.Display.Color.HexStringToColor(statusColour(tech)).color;
|
|
edges.lineStyle(isCurrent ? 3 : 2, colourInt, isCurrent ? 1 : 0.55);
|
|
edges.lineBetween(from.x, from.y, to.x, to.y);
|
|
}
|
|
|
|
let maxRight = innerX0;
|
|
let maxBottom = innerY0;
|
|
let minTop = innerY0;
|
|
for (const tech of rules.techsByField[selectedField]) {
|
|
const known = !!emp.known[tech.id];
|
|
// A branched rung can have more than one open (□) tech at once — this
|
|
// is what makes it clickable: canResearch is the same gate
|
|
// setResearchTarget itself checks, so "clickable" and "would actually
|
|
// succeed" never disagree.
|
|
const selectable = !known && canResearch(rules, state, e, tech);
|
|
const colour = statusColour(tech);
|
|
const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕');
|
|
const { x, y } = pos.get(tech.id);
|
|
const isCurrent = tech.id === targetId;
|
|
|
|
const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9)
|
|
.setStrokeStyle(isCurrent ? 2.5 : 1.5, Phaser.Display.Color.HexStringToColor(colour).color, isCurrent ? 1 : 0.7)
|
|
.setInteractive({ useHandCursor: selectable });
|
|
tree.add(badge);
|
|
tree.add(scene.add.text(x, y, mark, { fontFamily: FONT, fontSize: '20px', color: colour }).setOrigin(0.5));
|
|
|
|
tooltip.attachTo(badge, () => describeTechTooltip(rules, state, emp, tech));
|
|
if (selectable) {
|
|
badge.on('pointerup', () => {
|
|
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
|
|
// The badge this listener is on is about to be destroyed by the
|
|
// rebuild below — Tooltip only clears itself on pointerout, which
|
|
// never fires for a destroyed object, so hide it explicitly first.
|
|
tooltip.hide();
|
|
buildGrid();
|
|
buildDetail();
|
|
});
|
|
}
|
|
|
|
maxRight = Math.max(maxRight, x + NODE / 2);
|
|
maxBottom = Math.max(maxBottom, y + NODE / 2);
|
|
minTop = Math.min(minTop, y - NODE / 2);
|
|
}
|
|
|
|
// Defensive two-axis scroll fallback — a no-op against today's data
|
|
// (everything fits, confirmed by the pixel math above), kept so a field
|
|
// that later grows past 10 tiers or a 3+-way branch doesn't silently
|
|
// render off-pane. Mirrors this file's own overflow/clamp pattern.
|
|
let scrollX = 0;
|
|
let scrollY = 0;
|
|
let overflowX = 0;
|
|
let overflowY = 0;
|
|
const applyScroll = () => {
|
|
overflowX = Math.max(0, maxRight - innerX1);
|
|
overflowY = Math.max(0, innerY0 - minTop) + Math.max(0, maxBottom - innerY1);
|
|
scrollX = Phaser.Math.Clamp(scrollX, 0, overflowX);
|
|
scrollY = Phaser.Math.Clamp(scrollY, 0, overflowY);
|
|
tree.x = -scrollX;
|
|
tree.y = -scrollY;
|
|
};
|
|
bg.on('wheel', (pointer, dx, dy) => { scrollX += dx * 0.5; scrollY += dy * 0.5; applyScroll(); });
|
|
|
|
applyScroll();
|
|
if (targetId && pos.has(targetId)) {
|
|
const t = pos.get(targetId);
|
|
scrollX = Phaser.Math.Clamp(t.x - (usableX0 + usableX1) / 2, 0, overflowX);
|
|
scrollY = Phaser.Math.Clamp(t.y - spineY, 0, overflowY);
|
|
}
|
|
applyScroll();
|
|
}
|
|
|
|
shell.add(scene.add.text(shell.body.x, shell.y + shell.height - 34,
|
|
'■ researched □ available ✕ not available to your species — acquire by trade, espionage or conquest', {
|
|
fontFamily: FONT, fontSize: '14px', color: '#6b7f96',
|
|
}));
|
|
|
|
buildGrid();
|
|
buildDetail();
|
|
|
|
return shell;
|
|
}
|