1
0
Fork 0

Added framework for personality, and for ships to react to battle events

This commit is contained in:
Michaël Lemaire 2017-10-08 23:26:33 +02:00
parent 74acbeb827
commit 0ff23c71a1
15 changed files with 536 additions and 197 deletions

View file

@ -100,6 +100,7 @@ Technical
* Pack all images in atlases
* Pack sounds
* Replace jasmine with mocha+chai
Network
-------

@ -1 +1 @@
Subproject commit f4fb99bb2a5b6dc393a4fa115d4bf25cdae12ccf
Subproject commit a530998523e8f8c7a37323d5dc047241f71f6a36

View file

@ -14,6 +14,9 @@ module TK.SpaceTac {
// Current connected player
player: Player
// Personality reactions
reactions: PersonalityReactions
// Starting location
start_location: StarLocation
@ -27,6 +30,7 @@ module TK.SpaceTac {
this.id = RandomGenerator.global.id(20);
this.universe = new Universe();
this.player = new Player(this.universe);
this.reactions = new PersonalityReactions();
this.start_location = new StarLocation();
}
@ -66,6 +70,8 @@ module TK.SpaceTac {
this.player = new Player(this.universe);
this.reactions = new PersonalityReactions();
if (fleet) {
this.setCampaignFleet(null, story);
}
@ -97,6 +103,7 @@ module TK.SpaceTac {
let battle = Battle.newQuickRandom(true, RandomGenerator.global.randInt(1, 10));
this.player = battle.fleets[0].player;
this.player.setBattle(battle);
this.reactions = new PersonalityReactions();
}
// Get currently played battle, null when none is in progress

36
src/core/Personality.ts Normal file
View file

@ -0,0 +1,36 @@
module TK.SpaceTac {
/**
* List of personality traits (may be used with "keyof").
*/
export interface IPersonalityTraits {
aggressive: number
funny: number
heroic: number
optimistic: number
}
/**
* A personality is a set of traits that defines how a character thinks and behaves
*
* Each trait is a number between -1 and 1
*
* In the game, a personality represents an artificial intelligence, and is transferable
* from one ship (body) to another. This is why a personality has a name
*/
export class Personality implements IPersonalityTraits {
// Name of this personality
name = ""
// Aggressive 1 / Poised -1
aggressive = 0
// Funny 1 / Serious -1
funny = 0
// Heroic 1 / Coward -1
heroic = 0
// Optimistic 1 / Pessimistic -1
optimistic = 0
}
}

View file

@ -0,0 +1,65 @@
/// <reference path="PersonalityReactions.ts" />
module TK.SpaceTac.Specs {
describe("PersonalityReactions", function () {
function apply(pool: ReactionPool): PersonalityReaction | null {
let reactions = new PersonalityReactions();
return reactions.check(new Player(), null, null, null, pool);
}
class FakeReaction extends PersonalityReactionConversation {
ships: Ship[]
constructor(ships: Ship[]) {
super([]);
this.ships = ships;
}
static cons(ships: Ship[]): FakeReaction {
return new FakeReaction(ships);
}
}
it("fetches ships from conditions", function () {
let reaction = apply({});
expect(reaction).toBeNull();
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
reaction = apply({
a: [() => [s1, s2], 1, [[() => 1, FakeReaction.cons]]],
});
expect(reaction).toEqual(new FakeReaction([s1, s2]));
})
it("applies weight on conditions", function () {
let s1 = new Ship(null, "S1");
let s2 = new Ship(null, "S2");
let reaction = apply({
a: [() => [s1], 1, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 0, [[() => 1, FakeReaction.cons]]],
});
expect(reaction).toEqual(new FakeReaction([s1]));
reaction = apply({
a: [() => [s1], 0, [[() => 1, FakeReaction.cons]]],
b: [() => [s2], 1, [[() => 1, FakeReaction.cons]]],
});
expect(reaction).toEqual(new FakeReaction([s2]));
})
it("checks for friendly fire", function () {
let condition = BUILTIN_REACTION_POOL['friendly_fire'][0];
let battle = new Battle();
let ship1a = battle.fleets[0].addShip();
let ship1b = battle.fleets[0].addShip();
let ship2a = battle.fleets[1].addShip();
let ship2b = battle.fleets[1].addShip();
expect(condition(ship1a.getPlayer(), battle, ship1a, new DamageEvent(ship1a, 50, 10))).toEqual([], "self shoot");
expect(condition(ship1a.getPlayer(), battle, ship1a, new DamageEvent(ship1b, 50, 10))).toEqual([ship1b, ship1a]);
expect(condition(ship1a.getPlayer(), battle, ship1a, new DamageEvent(ship2a, 50, 10))).toEqual([], "enemy shoot");
expect(condition(ship1a.getPlayer(), battle, ship2a, new DamageEvent(ship2a, 50, 10))).toEqual([], "other player event");
})
})
}

View file

@ -0,0 +1,102 @@
module TK.SpaceTac {
// Reaction triggered
export type PersonalityReaction = PersonalityReactionConversation
// Condition to check if a reaction may happen, returning involved ships (order is important)
export type ReactionCondition = (player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleEvent | null) => Ship[]
// Reaction profile, giving a probability for types of personality, and an associated reaction constructor
export type ReactionProfile = [(traits: IPersonalityTraits) => number, (ships: Ship[]) => PersonalityReaction]
// Reaction config (condition, chance, profiles)
export type ReactionConfig = [ReactionCondition, number, ReactionProfile[]]
// Pool of reaction config
export type ReactionPool = { [code: string]: ReactionConfig }
/**
* Reactions to external events according to personalities.
*
* This allows for a more "alive" world, as characters tend to speak to react to events.
*
* This object will store the previous reactions to avoid too much recurrence, and should be global to a whole
* game session.
*/
export class PersonalityReactions {
done: string[] = []
random = RandomGenerator.global
/**
* Check for a reaction.
*
* This will return a reaction to display, and add it to the done list
*/
check(player: Player, battle: Battle | null = null, ship: Ship | null = null, event: BaseBattleEvent | null = null, pool: ReactionPool = BUILTIN_REACTION_POOL): PersonalityReaction | null {
let codes = difference(keys(pool), this.done);
let candidates = nna(codes.map((code: string): [string, Ship[], ReactionProfile[]] | null => {
let [condition, chance, profiles] = pool[code];
if (this.random.random() <= chance) {
let involved = condition(player, battle, ship, event);
if (involved.length > 0) {
return [code, involved, profiles];
} else {
return null;
}
} else {
return null;
}
}));
if (candidates.length > 0) {
let [code, involved, profiles] = this.random.choice(candidates);
let primary = involved[0];
let weights = profiles.map(([evaluator, _]) => evaluator(primary.personality));
let action_number = this.random.weighted(weights);
if (action_number >= 0) {
this.done.push(code);
let reaction_constructor = profiles[action_number][1];
return reaction_constructor(involved);
} else {
return null;
}
} else {
return null;
}
}
}
/**
* One kind of personality reaction: saying something out loud
*/
export class PersonalityReactionConversation {
messages: { interlocutor: Ship, message: string }[]
constructor(messages: { interlocutor: Ship, message: string }[]) {
this.messages = messages;
}
}
/**
* Standard reaction pool
*/
export const BUILTIN_REACTION_POOL: ReactionPool = {
friendly_fire: [cond_friendly_fire, 1, [
[traits => 1, ships => new PersonalityReactionConversation([
{ interlocutor: ships[0], message: "Hey !!! Watch where you're shooting !" },
{ interlocutor: ships[1], message: "Sorry mate..." },
])]
]]
}
function cond_friendly_fire(player: Player, battle: Battle | null, ship: Ship | null, event: BaseBattleEvent | null): Ship[] {
if (battle && ship && event) {
if (event instanceof DamageEvent && event.ship != ship && event.ship.getPlayer() == player && ship.getPlayer() == player) {
return [event.ship, ship];
} else {
return [];
}
} else {
return [];
}
}
}

View file

@ -45,6 +45,9 @@ module TK.SpaceTac {
// Ship values
values = new ShipValues()
// Personality
personality = new Personality()
// Boolean set to true if the ship is currently playing its turn
playing = false

View file

@ -142,6 +142,12 @@ module TK.SpaceTac.UI {
}
}
shutdown() {
super.shutdown();
this.log_processor.destroy();
}
/**
* Make the AI play current ship
*
@ -265,10 +271,10 @@ module TK.SpaceTac.UI {
this.action_bar.setInteractive(enabled);
this.exitTargettingMode();
this.interacting = enabled;
}
if (!enabled) {
this.setShipHovered(null);
if (!enabled) {
this.setShipHovered(null);
}
}
}

View file

@ -23,6 +23,9 @@ module TK.SpaceTac.UI {
// Current position in the battle log
private cursor = -1
// Indicator that the log is destroyed
private destroyed = false
// Indicator that the log is being played continuously
private playing = false
@ -32,6 +35,9 @@ module TK.SpaceTac.UI {
// Time at which the last action was applied
private last_action: number
// Playing ship (at current log position)
private current_ship = new Ship()
constructor(view: BattleView) {
this.view = view;
this.battle = view.battle;
@ -56,58 +62,70 @@ module TK.SpaceTac.UI {
*/
start() {
this.cursor = this.log.events.length - 1;
this.current_ship = new Ship();
this.battle.getBootstrapEvents().forEach(event => this.processBattleEvent(event));
this.playing = true;
if (!this.view.gameui.headless) {
// This will be asynchronous background work, until "destroy" is called
this.playContinuous();
}
}
/**
* Destroy the processor
*
* This should be done to ensure it will stop processing and free resources
*/
destroy() {
this.destroyed = true;
}
/**
* Play the log continuously
*/
playContinuous() {
async playContinuous() {
let delay = 0;
if (this.playing && !this.view.game.paused) {
delay = this.stepForward();
if (delay == 0) {
delay = this.transferControl();
while (!this.destroyed) {
if (this.playing && !this.view.game.paused) {
await this.stepForward();
await this.transferControl();
}
if (this.atEnd()) {
await this.view.timer.sleep(50);
}
}
this.view.timer.schedule(Math.max(delay, 50), () => this.playContinuous());
}
/**
* Make a step backward in time
*/
stepBackward() {
stepBackward(): Promise<void> {
if (!this.atStart()) {
this.cursor -= 1;
this.playing = false;
this.processBattleEvent(this.log.events[this.cursor + 1].getReverse());
return this.processBattleEvent(this.log.events[this.cursor + 1].getReverse());
} else {
return Promise.resolve();
}
}
/**
* Make a step forward in time
*
* Returns the duration of the step processing
*/
stepForward(): number {
stepForward(): Promise<void> {
if (!this.atEnd()) {
this.cursor += 1;
let result = this.processBattleEvent(this.log.events[this.cursor]);
if (this.atEnd()) {
this.playing = true;
}
return result;
return this.processBattleEvent(this.log.events[this.cursor]);
} else {
return 0;
return Promise.resolve();
}
}
@ -151,7 +169,7 @@ module TK.SpaceTac.UI {
* Check if we need a player or AI to interact at this point
*/
getPlayerNeeded(): Player | null {
if (this.atEnd()) {
if (this.playing && this.atEnd()) {
return this.battle.playing_ship ? this.battle.playing_ship.getPlayer() : null;
} else {
return null;
@ -186,7 +204,7 @@ module TK.SpaceTac.UI {
/**
* Process a single event
*/
processBattleEvent(event: BaseBattleEvent): number {
async processBattleEvent(event: BaseBattleEvent): Promise<void> {
if (this.debug) {
console.log("Battle event", event);
}
@ -221,34 +239,54 @@ module TK.SpaceTac.UI {
durations.push(this.processDroneAppliedEvent(event));
}
return max([0].concat(durations));
let delay = max([0].concat(durations));
if (delay) {
await this.view.timer.sleep(delay);
}
if (this.playing) {
let reaction = this.view.session.reactions.check(this.view.player, this.battle, this.current_ship, event);
if (reaction) {
await this.processReaction(reaction);
}
}
}
/**
* Transfer control to the needed player (or not)
*/
private transferControl(): number {
private async transferControl(): Promise<void> {
let player = this.getPlayerNeeded();
if (player) {
if (this.battle.playing_ship && !this.battle.playing_ship.alive) {
this.view.setInteractionEnabled(false);
this.battle.advanceToNextShip();
return 200;
await this.view.timer.sleep(200);
} else if (player === this.view.player) {
this.view.setInteractionEnabled(true);
return 0;
} else {
this.view.playAI();
return 0;
}
} else {
this.view.setInteractionEnabled(false);
return 0;
}
}
/**
* Process a personality reaction
*/
private async processReaction(reaction: PersonalityReaction): Promise<void> {
if (reaction instanceof PersonalityReactionConversation) {
let conversation = UIConversation.newFromPieces(this.view, reaction.messages);
await conversation.waitEnd();
} else {
console.warn("[LogProcessor] Unknown personality reaction type", reaction);
}
}
// Playing ship changed
private processShipChangeEvent(event: ShipChangeEvent): number {
this.current_ship = event.new_ship;
this.view.arena.setShipPlaying(event.new_ship);
this.view.ship_list.setPlaying(event.new_ship);
if (event.ship !== event.new_ship) {

View file

@ -0,0 +1,171 @@
/// <reference path="UIComponent.ts" />
module TK.SpaceTac.UI {
export type UIConversationPiece = { interlocutor: Ship, message: string }
export type UIConversationCallback = (conversation: UIConversation, step: number) => boolean
/**
* Style for a conversational message display
*/
export class UIConversationStyle {
// Center the message or not
center = false
// Padding between the content and the external border
padding = 10
// Background fill color
background = 0x202225
alpha = 0.9
// Border color and width
border = 0x404450
border_width = 2
// Text font style
text_color = "#ffffff"
text_size = 20
text_bold = true
// Portrait or image to display (from atlases)
image = ""
image_size = 0
image_caption = ""
}
/**
* Rectangle to display a message that may appear progressively, as in conversations
*/
export class UIConversationMessage extends UIComponent {
constructor(parent: BaseView | UIComponent, width: number, height: number, message: string, style = new UIConversationStyle()) {
super(parent, width, height);
this.drawBackground(style.background, style.border, style.border_width, style.alpha);
let offset = 0;
if (style.image_size && style.image) {
offset = style.image_size + style.padding;
width -= offset;
let ioffset = style.padding + Math.floor(style.image_size / 2);
this.addImage(ioffset, ioffset, style.image);
if (style.image_caption) {
let text_size = Math.ceil(style.text_size * 0.6);
this.addText(ioffset, style.padding + style.image_size + text_size, style.image_caption,
style.text_color, text_size, false, true, style.image_size);
}
}
let text = this.addText(offset + (style.center ? width / 2 : style.padding), style.center ? height / 2 : style.padding, message,
style.text_color, style.text_size, style.text_bold, style.center, width - style.padding * 2, style.center);
let i = 0;
let colorchar = () => {
text.clearColors();
if (i < message.length) {
text.addColor("transparent", i);
i++;
this.view.timer.schedule(10, colorchar);
}
}
colorchar();
}
}
/**
* Display of an active conversation (sequence of messages)
*/
export class UIConversation extends UIComponent {
private step = -1
private on_step: UIConversationCallback
private ended = false
private on_end = new Phaser.Signal()
constructor(parent: BaseView, on_step: UIConversationCallback) {
super(parent, parent.getWidth(), parent.getHeight());
this.drawBackground(0x404450, undefined, undefined, 0.7, () => this.forward());
this.setVisible(false);
this.on_step = on_step;
this.forward();
}
destroy() {
if (!this.ended) {
this.ended = true;
this.on_end.dispatch();
}
super.destroy();
}
/**
* Promise to wait for the end of conversation
*/
waitEnd(): Promise<void> {
if (this.ended) {
return Promise.resolve();
} else {
return new Promise((resolve, reject) => {
this.on_end.addOnce(resolve);
});
}
}
/**
* Set the currently displayed message
*/
setCurrentMessage(style: UIConversationStyle, content: string, width: number, height: number, relx: number, rely: number): void {
this.clearContent();
let message = new UIConversationMessage(this, width, height, content, style);
message.addButton(width - 60, height - 60, () => this.forward(), "common-arrow");
message.setPositionInsideParent(relx, rely);
this.setVisible(true, 700);
}
/**
* Convenience to set the current message from a ship
*
* This will automatically set the style and position of the message
*/
setCurrentShipMessage(ship: Ship, content: string): void {
let style = new UIConversationStyle();
style.image = `ship-${ship.model.code}-portrait`;
style.image_caption = ship.name;
style.image_size = 256;
let own = ship.getPlayer() == this.view.gameui.session.player;
this.setCurrentMessage(style, content, 900, 300, own ? 0.1 : 0.9, own ? 0.2 : 0.8);
}
/**
* Go forward to the next message
*/
forward(): void {
this.step += 1;
if (!this.on_step(this, this.step)) {
this.destroy();
}
}
/**
* Convenience to create a conversation from a list of pieces
*/
static newFromPieces(view: BaseView, pieces: UIConversationPiece[]): UIConversation {
let result = new UIConversation(view, (conversation, step) => {
if (step >= pieces.length) {
return false;
} else {
conversation.setCurrentShipMessage(pieces[step].interlocutor, pieces[step].message);
return true;
}
});
return result;
}
}
}

View file

@ -150,9 +150,9 @@ module TK.SpaceTac.UI {
*/
protected message(message: string, layer = 2, clear = true): Function {
return () => {
let style = new ProgressiveMessageStyle();
let style = new UIConversationStyle();
style.center = true;
let display = new ProgressiveMessage(this.view, 900, 200, message, style);
let display = new UIConversationMessage(this.view, 900, 200, message, style);
display.setPositionInsideParent(0.5, 0.9);
display.moveToLayer(this.getLayer(layer, clear));
display.setVisible(false);

View file

@ -1,70 +0,0 @@
module TK.SpaceTac.UI {
/**
* Style for a message display
*/
export class ProgressiveMessageStyle {
// Center the message or not
center = false
// Padding between the content and the external border
padding = 10
// Background fill color
background = 0x202225
alpha = 0.9
// Border color and width
border = 0x404450
border_width = 2
// Text font style
text_color = "#ffffff"
text_size = 20
text_bold = true
// Portrait or image to display (from atlases)
image = ""
image_size = 0
image_caption = ""
}
/**
* Rectangle to display a message that may appear progressively, as in dialogs
*/
export class ProgressiveMessage extends UIComponent {
constructor(parent: BaseView | UIComponent, width: number, height: number, message: string, style = new ProgressiveMessageStyle()) {
super(parent, width, height);
this.drawBackground(style.background, style.border, style.border_width, style.alpha);
let offset = 0;
if (style.image_size && style.image) {
offset = style.image_size + style.padding;
width -= offset;
let ioffset = style.padding + Math.floor(style.image_size / 2);
this.addImage(ioffset, ioffset, style.image);
if (style.image_caption) {
let text_size = Math.ceil(style.text_size * 0.6);
this.addText(ioffset, style.padding + style.image_size + text_size, style.image_caption,
style.text_color, text_size, false, true, style.image_size);
}
}
let text = this.addText(offset + (style.center ? width / 2 : style.padding), style.center ? height / 2 : style.padding, message,
style.text_color, style.text_size, style.text_bold, style.center, width - style.padding * 2, style.center);
let i = 0;
let colorchar = () => {
text.clearColors();
if (i < message.length) {
text.addColor("transparent", i);
i++;
this.view.timer.schedule(10, colorchar);
}
}
colorchar();
}
}
}

View file

@ -1,94 +0,0 @@
module TK.SpaceTac.UI {
/**
* Display of an active conversation
*/
export class ConversationDisplay extends UIComponent {
dialog: MissionPartConversation | null = null
player: Player
on_end: Function | null = null
constructor(parent: BaseView, player: Player) {
super(parent, parent.getWidth(), parent.getHeight());
this.player = player;
this.drawBackground(0x404450, undefined, undefined, 0.7, () => this.nextPiece());
this.setVisible(false);
}
/**
* Update from currently active missions
*/
updateFromMissions(missions: ActiveMissions, on_end: Function | null = null) {
let parts = missions.getCurrent().map(mission => mission.current_part);
this.dialog = <MissionPartConversation | null>first(parts, part => part instanceof MissionPartConversation);
if (this.dialog) {
this.on_end = on_end;
} else {
this.on_end = null;
}
this.refresh();
}
/**
* Go to the next dialog piece
*/
nextPiece(): void {
if (this.dialog) {
this.dialog.next();
this.refresh();
}
}
/**
* Skip the conversation
*/
skipConversation(): void {
if (this.dialog) {
this.dialog.skip();
this.refresh();
}
}
/**
* Refresh the displayed dialog piece
*/
refresh() {
this.clearContent();
if (this.dialog) {
if (this.dialog.checkCompleted()) {
if (this.on_end) {
this.on_end();
this.on_end = null;
}
this.setVisible(false, 700);
} else {
let piece = this.dialog.getCurrent();
let style = new ProgressiveMessageStyle();
if (piece.interlocutor) {
style.image = `ship-${piece.interlocutor.model.code}-portrait`;
style.image_caption = piece.interlocutor.name;
style.image_size = 256;
}
let message = new ProgressiveMessage(this, 900, 300, piece.message, style);
message.addButton(840, 240, () => this.nextPiece(), "common-arrow");
if (piece.interlocutor && piece.interlocutor.getPlayer() === this.player) {
message.setPositionInsideParent(0.1, 0.2);
} else {
message.setPositionInsideParent(0.9, 0.8);
}
this.setVisible(true, 700);
}
} else {
this.setVisible(false);
}
}
}
}

View file

@ -0,0 +1,74 @@
/// <reference path="../common/UIConversation.ts" />
module TK.SpaceTac.UI {
/**
* Display of an active mission conversation
*/
export class MissionConversationDisplay extends UIConversation {
dialog: MissionPartConversation | null = null
on_ended: Function | null = null
constructor(parent: BaseView) {
super(parent, () => true);
}
/**
* Update from currently active missions
*/
updateFromMissions(missions: ActiveMissions, on_ended: Function | null = null) {
let parts = missions.getCurrent().map(mission => mission.current_part);
this.dialog = <MissionPartConversation | null>first(parts, part => part instanceof MissionPartConversation);
if (this.dialog) {
this.on_ended = on_ended;
} else {
this.on_ended = null;
}
this.refresh();
}
/**
* Go to the next dialog piece
*/
forward(): void {
if (this.dialog) {
this.dialog.next();
this.refresh();
}
}
/**
* Skip the conversation
*/
skipConversation(): void {
if (this.dialog) {
this.dialog.skip();
this.refresh();
}
}
/**
* Refresh the displayed dialog piece
*/
refresh() {
this.clearContent();
if (this.dialog) {
if (this.dialog.checkCompleted()) {
if (this.on_ended) {
this.on_ended();
this.on_ended = null;
}
this.setVisible(false, 700);
} else {
let piece = this.dialog.getCurrent();
this.setCurrentShipMessage(piece.interlocutor || new Ship(), piece.message);
this.setVisible(true, 700);
}
} else {
this.setVisible(false);
}
}
}
}

View file

@ -37,7 +37,7 @@ module TK.SpaceTac.UI {
// Active missions
missions: ActiveMissionsDisplay
conversation: ConversationDisplay
conversation: MissionConversationDisplay
// Character sheet
character_sheet: CharacterSheet
@ -127,13 +127,13 @@ module TK.SpaceTac.UI {
this.character_sheet.hide(false);
this.layer_overlay.add(this.character_sheet);
this.conversation = new ConversationDisplay(this, this.player);
this.conversation = new MissionConversationDisplay(this);
this.conversation.moveToLayer(this.layer_overlay);
this.gameui.audio.startMusic("spring-thaw");
// Inputs
this.inputs.bind(" ", "Conversation step", () => this.conversation.nextPiece());
this.inputs.bind(" ", "Conversation step", () => this.conversation.forward());
this.inputs.bind("Escape", "Skip conversation", () => this.conversation.skipConversation());
this.inputs.bindCheat("r", "Reveal whole map", () => this.revealAll());
this.inputs.bindCheat("n", "Next story step", () => {