diff --git a/TODO b/TODO index 5ef4e12..40f4dcc 100644 --- a/TODO +++ b/TODO @@ -1,7 +1,11 @@ * UI: Use a common component class, and a layer abstraction +* UI: Fix tooltip sometimes being enormous * Character sheet: add tooltips (on values, slots and equipments) * Character sheet: add initial character creation * Character sheet: disable interaction during battle (except for loot screen) +* Character sheet: paginate loot and shop items +* Character sheet: improve eye-catching for shop and loot section +* Shops: add equipment pricing, with usage depreciation * Add battle statistics and/or critics in outcome dialog * Add battle experience and feedback on level up * Ensure that tweens and particle emitters get destroyed once animation is done (or view changes) @@ -32,10 +36,9 @@ * TacticalAI: allow to play several moves in the same turn * TacticalAI: add pauses to not play too quickly * TacticalAI: replace BullyAI -* Add retreat from battle * Map: restore fog of war * Map: add information on current star/location + information on hovered location -* Map: add stores and shipyards +* Map: add shops and shipyards * Map: remove jump links that cross the radius of other systems * Map: disable interaction (zoom, selection) while moving/jumping * Menu: fix background stars aggregating at right side when the game is not focused diff --git a/graphics/ui/character.svg b/graphics/ui/character.svg index 51642e3..8619173 100644 --- a/graphics/ui/character.svg +++ b/graphics/ui/character.svg @@ -16,7 +16,7 @@ viewBox="0 0 507.99999 285.75001" version="1.1" id="svg8" - inkscape:version="0.92.0 r15299" + inkscape:version="0.92.1 r15371" sodipodi:docname="character.svg" enable-background="new" inkscape:export-filename="/home/michael/workspace/perso/spacetac/out/assets/images/character/close.png" @@ -24,6 +24,22 @@ inkscape:export-ydpi="96"> + + + + + + + + + - Artana come from a long line of merchants, and hasalways valued peace overbrutality. She is pragmaticand resolute. X + + + + + Lootable items + + + Sell for 150 + + - Z - - + transform="translate(0,-0.44954215)" + id="g6698"> + Z + + + + + + + + 150 + + + diff --git a/graphics/ui/map.svg b/graphics/ui/map.svg index 46155b1..89587c8 100644 --- a/graphics/ui/map.svg +++ b/graphics/ui/map.svg @@ -338,9 +338,9 @@ borderopacity="1.0" inkscape:pageopacity="0" inkscape:pageshadow="2" - inkscape:zoom="2.8284271" - inkscape:cx="1297.0192" - inkscape:cy="519.42484" + inkscape:zoom="16" + inkscape:cx="992.35147" + inkscape:cy="383.97616" inkscape:document-units="mm" inkscape:current-layer="layer5" showgrid="false" @@ -674,6 +674,59 @@ id="rect4920" style="fill:none;fill-rule:evenodd;stroke:#cdd8e2;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.76382979" /> + + + + + Z + + + + + + { + it("generates a stock", () => { + let shop = new Shop(); + expect(shop.stock.length).toBe(0); + + shop.generateStock(8); + expect(shop.stock.length).toBe(8); + }); + + it("buys and sells items", function () { + let shop = new Shop(); + let equ1 = new Equipment(SlotType.Shield, "shield"); + let equ2 = new Equipment(SlotType.Hull, "hull"); + shop.stock = [equ1, equ2]; + let fleet = new Fleet(); + fleet.credits = 1000; + spyOn(shop, "getPrice").and.returnValue(800); + + let result = shop.sellToFleet(equ1, fleet); + expect(result).toBe(true); + expect(shop.stock).toEqual([equ2]); + expect(fleet.credits).toEqual(200); + result = shop.sellToFleet(equ2, fleet); + expect(result).toBe(false); + expect(shop.stock).toEqual([equ2]); + expect(fleet.credits).toEqual(200); + + result = shop.buyFromFleet(equ1, fleet); + expect(result).toBe(true); + expect(shop.stock).toEqual([equ2, equ1]); + expect(fleet.credits).toEqual(1000); + }); + }); +} diff --git a/src/core/Shop.ts b/src/core/Shop.ts new file mode 100644 index 0000000..321314d --- /dev/null +++ b/src/core/Shop.ts @@ -0,0 +1,68 @@ +module TS.SpaceTac { + /** + * A shop is a place to buy/sell equipments + */ + export class Shop { + // Equipment in stock + stock: Equipment[] = []; + + /** + * Generate a random stock + */ + generateStock(items: number) { + let generator = new LootGenerator(); + this.stock = nna(range(items).map(i => generator.generate())); + + this.sortStock(); + } + + /** + * Sort the stock by equipment level, then by value + */ + sortStock() { + // TODO + } + + /** + * Get the buy/sell price for an equipment + */ + getPrice(equipment: Equipment): number { + // TODO + return 100; + } + + /** + * A fleet buys an item + * + * This does not put the item anywhere on the fleet, only remove the item from stock, and make the payment + */ + sellToFleet(equipment: Equipment, fleet: Fleet) { + let price = this.getPrice(equipment); + if (price <= fleet.credits) { + if (remove(this.stock, equipment)) { + fleet.credits -= price; + return true; + } else { + return false; + } + } else { + return false; + } + } + + /** + * A fleet sells an item + * + * This does not check if the item is anywhere on the fleet, only add the item to the shop stock, and make the payment + */ + buyFromFleet(equipment: Equipment, fleet: Fleet) { + let price = this.getPrice(equipment); + if (add(this.stock, equipment)) { + fleet.credits += price; + return true; + } else { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/core/StarLocation.ts b/src/core/StarLocation.ts index 7f8df0c..a27942f 100644 --- a/src/core/StarLocation.ts +++ b/src/core/StarLocation.ts @@ -31,6 +31,9 @@ module TS.SpaceTac { encounter_gen = false; encounter_random = RandomGenerator.global; + // Shop to buy/sell equipment + shop: Shop | null = null; + constructor(star = new Star(), type: StarLocationType = StarLocationType.PLANET, x: number = 0, y: number = 0) { this.star = star; this.type = type; @@ -41,6 +44,16 @@ module TS.SpaceTac { this.jump_dest = null; } + /** + * Add a shop in this location + */ + addShop(generate_items = 0) { + this.shop = new Shop(); + if (generate_items) { + this.shop.generateStock(generate_items); + } + } + // Set the jump destination of a WARP location setJumpDestination(jump_dest: StarLocation): void { if (this.type === StarLocationType.WARP) { @@ -99,6 +112,7 @@ module TS.SpaceTac { * Clear an encounter, when the encountered fleet has been defeated */ clearEncounter() { + this.encounter_gen = true; this.encounter = null; } } diff --git a/src/ui/Preload.ts b/src/ui/Preload.ts index 2270cff..f0cefbe 100644 --- a/src/ui/Preload.ts +++ b/src/ui/Preload.ts @@ -89,6 +89,7 @@ module TS.SpaceTac.UI { this.loadImage("map/state-unknown.png"); this.loadImage("map/state-enemy.png"); this.loadImage("map/state-clear.png"); + this.loadImage("map/state-shop.png"); this.loadImage("character/sheet.png"); this.loadImage("character/close.png"); this.loadImage("character/ship.png"); @@ -101,6 +102,8 @@ module TS.SpaceTac.UI { this.loadImage("character/slot-shield.png"); this.loadImage("character/slot-engine.png"); this.loadImage("character/slot-weapon.png"); + this.loadImage("character/price-tag.png"); + this.loadImage("character/scroll.png"); this.loadImage("equipment/ironhull.png"); this.loadImage("equipment/basicforcefield.png"); this.loadImage("equipment/basicpowercore.png"); diff --git a/src/ui/character/CharacterCargo.ts b/src/ui/character/CharacterCargo.ts index 6641cd7..dcaab3f 100644 --- a/src/ui/character/CharacterCargo.ts +++ b/src/ui/character/CharacterCargo.ts @@ -26,6 +26,9 @@ module TS.SpaceTac.UI { scale: this.scale.x } } + getPriceOffset(): number { + return 82; + } addEquipment(equipment: CharacterEquipment, source: CharacterEquipmentContainer | null, test: boolean): boolean { if (this.sheet.ship.getFreeCargoSpace() > 0) { if (test) { diff --git a/src/ui/character/CharacterEquipment.ts b/src/ui/character/CharacterEquipment.ts index 4fe4fcd..14b6a4a 100644 --- a/src/ui/character/CharacterEquipment.ts +++ b/src/ui/character/CharacterEquipment.ts @@ -11,6 +11,10 @@ module TS.SpaceTac.UI { * Get a centric anchor point and scaling to snap the equipment */ getEquipmentAnchor(): { x: number, y: number, scale: number } + /** + * Get a vertical offset to position the price tag + */ + getPriceOffset(): number /** * Add an equipment to the container */ @@ -29,6 +33,7 @@ module TS.SpaceTac.UI { item: Equipment container: CharacterEquipmentContainer tooltip: string + price: number constructor(sheet: CharacterSheet, equipment: Equipment, container: CharacterEquipmentContainer) { let icon = sheet.game.cache.checkImageKey(`equipment-${equipment.code}`) ? `equipment-${equipment.code}` : `battle-actions-${equipment.action.code}`; @@ -38,6 +43,7 @@ module TS.SpaceTac.UI { this.item = equipment; this.container = container; this.tooltip = equipment.name; + this.price = 0; this.container.addEquipment(this, null, false); @@ -57,6 +63,28 @@ module TS.SpaceTac.UI { return ifirst(this.sheet.iEquipmentContainers(), container => container.isInside(x, y)); } + /** + * Display a price tag + */ + setPrice(price: number) { + if (!price || this.price) { + return; + } + this.price = price; + + let tag = new Phaser.Image(this.game, 0, 0, "character-price-tag"); + let yoffset = this.container.getPriceOffset(); + tag.position.set(0, -yoffset * 2 + tag.height); + tag.anchor.set(0.5, 0.5); + tag.scale.set(2, 2); + tag.alpha = 0.85; + this.addChild(tag); + + let text = new Phaser.Text(this.game, -10, 4, price.toString(), { align: "center", font: "18pt Arial", fill: "#FFFFCC" }); + text.anchor.set(0.5, 0.5); + tag.addChild(text); + } + /** * Snap in place to its current container */ diff --git a/src/ui/character/CharacterFleetMember.ts b/src/ui/character/CharacterFleetMember.ts index 9efc7d4..86707c7 100644 --- a/src/ui/character/CharacterFleetMember.ts +++ b/src/ui/character/CharacterFleetMember.ts @@ -39,6 +39,9 @@ module TS.SpaceTac.UI { // not needed, equipment is never shown snapped in the slot return { x: 0, y: 0, scale: 1 }; } + getPriceOffset(): number { + return 0; + } addEquipment(equipment: CharacterEquipment, source: CharacterEquipmentContainer | null, test: boolean): boolean { if (this.ship != this.sheet.ship && equipment.item.slot !== null) { let slot = this.ship.getFreeSlot(equipment.item.slot); diff --git a/src/ui/character/CharacterLootSlot.spec.ts b/src/ui/character/CharacterLootSlot.spec.ts new file mode 100644 index 0000000..0708ab3 --- /dev/null +++ b/src/ui/character/CharacterLootSlot.spec.ts @@ -0,0 +1,43 @@ +module TS.SpaceTac.UI.Specs { + describe("CharacterLootSlot", function () { + let testgame = setupEmptyView(); + + it("takes or discard loot", function () { + let view = testgame.baseview; + let sheet = new CharacterSheet(view); + + let fleet = new Fleet(); + let ship = fleet.addShip(); + ship.setCargoSpace(2); + let equ1 = new Equipment(SlotType.Shield, "equ1"); + ship.addCargo(equ1) + + let equ2 = new Equipment(SlotType.Weapon, "equ2"); + let loot = [equ2]; + sheet.setLoot(loot); + sheet.show(ship); + + expect(ship.cargo).toEqual([equ1]); + expect(loot).toEqual([equ2]); + + let cargo_slot = sheet.ship_cargo.children[0]; + expect(cargo_slot instanceof CharacterCargo).toBe(true); + let loot_slot = sheet.loot_slots.children[0]; + expect(loot_slot instanceof CharacterLootSlot).toBe(true); + + // loot to cargo + let equ2s = sheet.equipments.children[1]; + expect(equ2s.item).toBe(equ2); + equ2s.applyDragDrop(loot_slot, cargo_slot, false); + expect(ship.cargo).toEqual([equ1, equ2]); + expect(loot).toEqual([]); + + // discard to cargo + let equ1s = sheet.equipments.children[0]; + expect(equ1s.item).toBe(equ1); + equ1s.applyDragDrop(cargo_slot, loot_slot, false); + expect(ship.cargo).toEqual([equ2]); + expect(loot).toEqual([equ1]); + }); + }); +} diff --git a/src/ui/character/CharacterLootSlot.ts b/src/ui/character/CharacterLootSlot.ts index 87352d1..11a98a4 100644 --- a/src/ui/character/CharacterLootSlot.ts +++ b/src/ui/character/CharacterLootSlot.ts @@ -1,3 +1,4 @@ +/// /// module TS.SpaceTac.UI { diff --git a/src/ui/character/CharacterSheet.ts b/src/ui/character/CharacterSheet.ts index 6c60f5e..8539099 100644 --- a/src/ui/character/CharacterSheet.ts +++ b/src/ui/character/CharacterSheet.ts @@ -37,10 +37,16 @@ module TS.SpaceTac.UI { // Ship cargo ship_cargo: Phaser.Group; + // Mode title + mode_title: Phaser.Text; + // Loot items loot_slots: Phaser.Group; loot_items: Equipment[] = []; + // Shop + shop: Shop | null = null; + // Fleet's portraits portraits: Phaser.Group; @@ -107,6 +113,10 @@ module TS.SpaceTac.UI { this.equipments = new Phaser.Group(this.game); this.addChild(this.equipments); + this.mode_title = new Phaser.Text(this.game, 1548, 648, "", { align: "center", font: "18pt Arial", fill: "#FFFFFF" }); + this.mode_title.anchor.set(0.5, 0.5); + this.addChild(this.mode_title); + let x1 = 664; let x2 = 1066; let y = 662; @@ -222,6 +232,10 @@ module TS.SpaceTac.UI { this.updateFleet(ship.fleet); + if (this.shop) { + this.updatePrices(this.shop); + } + if (animate) { this.game.tweens.create(this).to({ x: this.xshown }, 800, Phaser.Easing.Circular.InOut, true); } else { @@ -233,7 +247,10 @@ module TS.SpaceTac.UI { * Hide the sheet */ hide(animate = true) { + this.loot_items = []; + this.shop = null; this.loot_slots.visible = false; + this.mode_title.visible = false; this.portraits.children.forEach((portrait: Phaser.Button) => portrait.loadTexture("character-ship")); @@ -248,11 +265,39 @@ module TS.SpaceTac.UI { * Set the list of lootable equipment * * The list of equipments may be altered if items are taken from it + * + * This list will be shown until sheet is closed */ setLoot(loot: Equipment[]) { this.loot_items = loot; this.updateLoot(); this.loot_slots.visible = true; + + this.mode_title.setText("Lootable items"); + this.mode_title.visible = true; + } + + /** + * Set the displayed shop + * + * This shop will be shown until sheet is closed + */ + setShop(shop: Shop) { + this.shop = shop; + this.updateLoot(); + this.loot_slots.visible = true; + + this.mode_title.setText("Shop's equipment"); + this.mode_title.visible = true; + } + + /** + * Update the price tags on each equipment, for a specific shop + */ + updatePrices(shop: Shop) { + this.equipments.children.forEach((equipement: CharacterEquipment) => { + equipement.setPrice(shop.getPrice(equipement.item)); + }); } /** @@ -263,13 +308,16 @@ module TS.SpaceTac.UI { let info = CharacterSheet.getSlotPositions(12, 588, 354, 196, 196); range(12).forEach(idx => { - let loot_slot = new CharacterLootSlot(this, info.positions[idx].x, info.positions[idx].y); + let loot_slot = this.shop ? new CharacterShopSlot(this, info.positions[idx].x, info.positions[idx].y) : new CharacterLootSlot(this, info.positions[idx].x, info.positions[idx].y); loot_slot.scale.set(info.scaling, info.scaling); this.loot_slots.addChild(loot_slot); if (idx < this.loot_items.length) { let equipment = new CharacterEquipment(this, this.loot_items[idx], loot_slot); this.equipments.addChild(equipment); + } else if (this.shop && idx < this.shop.stock.length) { + let equipment = new CharacterEquipment(this, this.shop.stock[idx], loot_slot); + this.equipments.addChild(equipment); } }); } diff --git a/src/ui/character/CharacterShopSlot.spec.ts b/src/ui/character/CharacterShopSlot.spec.ts new file mode 100644 index 0000000..4de3597 --- /dev/null +++ b/src/ui/character/CharacterShopSlot.spec.ts @@ -0,0 +1,57 @@ +module TS.SpaceTac.UI.Specs { + describe("CharacterShopSlot", function () { + let testgame = setupEmptyView(); + + it("buys and sell if bound to a shop", function () { + let view = testgame.baseview; + let sheet = new CharacterSheet(view); + + let fleet = new Fleet(); + fleet.credits = 100; + let ship = fleet.addShip(); + ship.setCargoSpace(2); + let equ1 = new Equipment(SlotType.Shield, "equ1"); + ship.addCargo(equ1) + + let equ2 = new Equipment(SlotType.Weapon, "equ2"); + let shop = new Shop(); + shop.stock = [equ2]; + spyOn(shop, "getPrice").and.returnValue(120); + sheet.setShop(shop); + sheet.show(ship); + + expect(ship.cargo).toEqual([equ1]); + expect(shop.stock).toEqual([equ2]); + expect(fleet.credits).toBe(100); + + let cargo_slot = sheet.ship_cargo.children[0]; + expect(cargo_slot instanceof CharacterCargo).toBe(true); + let shop_slot = sheet.loot_slots.children[0]; + expect(shop_slot instanceof CharacterShopSlot).toBe(true); + + // sell + let equ1s = sheet.equipments.children[0]; + expect(equ1s.item).toBe(equ1); + equ1s.applyDragDrop(cargo_slot, shop_slot, false); + expect(ship.cargo).toEqual([]); + expect(shop.stock).toEqual([equ2, equ1]); + expect(fleet.credits).toBe(220); + + // buy + let equ2s = sheet.equipments.children[1]; + expect(equ2s.item).toBe(equ2); + equ2s.applyDragDrop(shop_slot, cargo_slot, false); + expect(ship.cargo).toEqual([equ2]); + expect(shop.stock).toEqual([equ1]); + expect(fleet.credits).toBe(100); + + // not enough money + equ1s = sheet.equipments.children[0]; + expect(equ1s.item).toBe(equ1); + equ1s.applyDragDrop(shop_slot, cargo_slot, false); + expect(ship.cargo).toEqual([equ2]); + expect(shop.stock).toEqual([equ1]); + expect(fleet.credits).toBe(100); + }); + }); +} diff --git a/src/ui/character/CharacterShopSlot.ts b/src/ui/character/CharacterShopSlot.ts new file mode 100644 index 0000000..4d56c87 --- /dev/null +++ b/src/ui/character/CharacterShopSlot.ts @@ -0,0 +1,35 @@ +/// + +module TS.SpaceTac.UI { + /** + * Display a shop slot + */ + export class CharacterShopSlot extends CharacterLootSlot { + addEquipment(equipment: CharacterEquipment, source: CharacterEquipmentContainer | null, test: boolean): boolean { + let shop = this.sheet.shop; + if (shop && !contains(shop.stock, equipment.item)) { + if (test) { + return true; + } else { + return shop.buyFromFleet(equipment.item, this.sheet.fleet); + } + } else { + return false; + } + } + + removeEquipment(equipment: CharacterEquipment, destination: CharacterEquipmentContainer | null, test: boolean): boolean { + let shop = this.sheet.shop; + if (shop && contains(shop.stock, equipment.item)) { + let price = shop.getPrice(equipment.item); + if (test) { + return price <= this.sheet.fleet.credits; + } else { + return shop.sellToFleet(equipment.item, this.sheet.fleet); + } + } else { + return false; + } + } + } +} diff --git a/src/ui/character/CharacterSlot.ts b/src/ui/character/CharacterSlot.ts index 1ef99f0..5962d0c 100644 --- a/src/ui/character/CharacterSlot.ts +++ b/src/ui/character/CharacterSlot.ts @@ -32,6 +32,9 @@ module TS.SpaceTac.UI { scale: this.scale.x } } + getPriceOffset(): number { + return 66; + } addEquipment(equipment: CharacterEquipment, source: CharacterEquipmentContainer | null, test: boolean): boolean { if (equipment.item.slot !== null && this.sheet.ship.getFreeSlot(equipment.item.slot)) { if (test) { diff --git a/src/ui/map/StarSystemDisplay.ts b/src/ui/map/StarSystemDisplay.ts index 94038f5..ecd8855 100644 --- a/src/ui/map/StarSystemDisplay.ts +++ b/src/ui/map/StarSystemDisplay.ts @@ -30,7 +30,16 @@ module TS.SpaceTac.UI { // Show locations starsystem.locations.map(location => { let location_sprite: Phaser.Image | null = null; - let fleet_move = () => this.fleet_display.moveToLocation(location); + let fleet_move = () => { + if (location == this.player.fleet.location) { + if (location.shop) { + this.view.character_sheet.setShop(location.shop); + this.view.character_sheet.show(this.player.fleet.ships[0]); + } + } else { + this.fleet_display.moveToLocation(location); + } + } if (location.type == StarLocationType.STAR) { location_sprite = this.addImage(location.x, location.y, "map-location-star", fleet_move); @@ -43,13 +52,15 @@ module TS.SpaceTac.UI { } this.view.tooltip.bindDynamicText(location_sprite, () => { + let visited = this.player.hasVisitedLocation(location); + let shop = (visited && !location.encounter && location.shop) ? " (shop present)" : ""; + if (location == this.player.fleet.location) { - return "Current fleet location"; + return `Current fleet location${shop}`; } else { - let visited = this.player.hasVisitedLocation(location); let loctype = StarLocationType[location.type].toLowerCase(); let danger = (visited && location.encounter) ? " [enemy fleet detected !]" : ""; - return `${visited ? "Visited" : "Unvisited"} ${loctype} - Move the fleet there${danger}`; + return `${visited ? "Visited" : "Unvisited"} ${loctype} - Move the fleet there${danger}${shop}`; } }); @@ -82,7 +93,17 @@ module TS.SpaceTac.UI { * Return the sprite code to use for visited status. */ getVisitedKey(location: StarLocation) { - return this.player.hasVisitedLocation(location) ? (location.encounter ? "map-state-enemy" : "map-state-clear") : "map-state-unknown"; + if (this.player.hasVisitedLocation(location)) { + if (location.encounter) { + return "map-state-enemy"; + } else if (location.shop) { + return "map-state-shop"; + } else { + return "map-state-clear"; + } + } else { + return "map-state-unknown"; + } } /**