Most of my programming experience is with Objective-C, but I have recently started learning Java and JavaFX to build the user interface for a Trading Card Game. This is the most Java I have ever written, and I am completely new to JavaFX as well, so I am sure that there are lots of problems with this code.
I mostly used Scene Builder to layout the scene itself, but I manually edited the FXMLDocument file also to accomplish everything. I am not at all sure that I am doing things the right way here. I have tried to make the code as readable as possible, but many objects are created programmatically and not with Scene Builder so I am not sure that everything will make sense to the reader.
I should note that I tried to use only Panes for containing the information and for rendering it, but I had issues with the scene not updating properly. To solve this problem, I created Lists that contain the Groups that the scene will actually render. This separates the rendering from the creation of the objects being rendered. I think this is a good approach, but I may be wrong.
What I am looking for are best practices for Java, as well as any Java features I may not be using that I should be. Also the readability of the code is always very important to me. Don't worry about being harsh or critical, you won't hurt my feelings.
Here is a sample image of the GUI in action. I should note that it is a prototype for the purpose of testing the game as quickly as possible, so it is ugly: game image 01
First things first, this is the class that is the launching point for the program.
JavaFXGame.java
package com.cardshifter.fx;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
//This class just loads the FXML document which initializes its DocumentController
public class JavaFXGame extends Application {
@Override
public void start(Stage stage) throws Exception {
//stage.setTitle("title");
Parent root = FXMLLoader.load(getClass().getResource("FXMLDocument.fxml"));
Scene scene = new Scene(root);
stage.setScene(scene);
//stage.centerOnScreen();
stage.show();
}
// @param args the command line arguments
public static void main(String[] args) {
launch(args);
}
}
This is the meat of the program, where the user interface is controlled and commands are sent to the Game:
FXMLGameController.java
package com.cardshifter.fx;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.ResourceBundle;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.QuadCurve;
import javafx.scene.shape.Rectangle;
import org.luaj.vm2.lib.jse.CoerceLuaToJava;
import com.cardshifter.ai.CardshifterAI;
import com.cardshifter.ai.CompleteIdiot;
import com.cardshifter.core.Card;
import com.cardshifter.core.Game;
import com.cardshifter.core.Player;
import com.cardshifter.core.Targetable;
import com.cardshifter.core.Zone;
import com.cardshifter.core.actions.TargetAction;
import com.cardshifter.core.actions.UsableAction;
import com.cardshifter.core.console.CommandLineOptions;
public class FXMLGameController implements Initializable {
//INITIAL GAME SETUP
private final CardshifterAI opponent = new CompleteIdiot();
//need a forward declaration so that this is global to the class
private Game game;
//hack to make the buttons work properly
private boolean gameHasStarted = false;
//I think this is a public constructor, this code initializes the Game
@FXML
Pane anchorPane;
public FXMLGameController() throws Exception {
this.initializeGame();
}
private void initializeGame() throws Exception {
CommandLineOptions options = new CommandLineOptions();
InputStream file = options.getScript() == null ? Game.class.getResourceAsStream("start.lua") : new FileInputStream(new File(options.getScript()));
game = new Game(file, options.getRandom());
}
//GAME START and NEW GAME
@FXML
private Label startGameLabel;
@FXML
private void startGameButtonAction(ActionEvent event) {
if (gameHasStarted == false) {
startGameLabel.setText("Starting Game");
game.getEvents().startGame(game);
gameHasStarted = true;
turnLabel.setText(String.format("Turn Number %d", game.getTurnNumber()));
this.playerBattlefieldData = new ArrayList<>();
this.opponentBattlefieldData = new ArrayList<>();
this.playerHandData = new ArrayList<>();
this.opponentHandData = new ArrayList<>();
this.createData();
this.render();
}
}
@FXML
private void newGameButtonAction(ActionEvent event) throws Exception {
this.initializeGame();
startGameLabel.setText("Starting Game");
game.getEvents().startGame(game);
gameHasStarted = true;
turnLabel.setText(String.format("Turn Number %d", game.getTurnNumber()));
this.playerBattlefieldData = new ArrayList<>();
this.opponentBattlefieldData = new ArrayList<>();
this.playerHandData = new ArrayList<>();
this.opponentHandData = new ArrayList<>();
this.createData();
this.render();
}
//TODO: Create a fixed time step and render in that
//RENDER - eventually change this to an Update method
public void render() {
//this is a hack to make the buttons disappear when the choice is made
//will not work with a fixed time step
Node choiceBoxPane = anchorPane.lookup("#choiceBoxPane");
anchorPane.getChildren().remove(choiceBoxPane);
this.renderHands();
this.renderBattlefields();
this.updateGameLabels();
}
//RENDER HANDS
@FXML
private Pane opponentHandPane;
@FXML
private Pane playerHandPane;
private void renderHands() {
this.renderOpponentHand();
this.renderPlayerHand();
}
private void renderOpponentHand() {
opponentHandPane.getChildren().clear();
opponentHandPane.getChildren().addAll(opponentHandData);
}
private void renderPlayerHand() {
playerHandPane.getChildren().clear();
playerHandPane.getChildren().addAll(playerHandData);
}
//RENDER BATTLEFIELDS
@FXML
Pane opponentBattlefieldPane;
@FXML
Pane playerBattlefieldPane;
private void renderBattlefields() {
this.renderOpponentBattlefield();
this.renderPlayerBattlefield();
}
private void renderOpponentBattlefield() {
opponentBattlefieldPane.getChildren().clear();
opponentBattlefieldPane.getChildren().addAll(opponentBattlefieldData);
}
private void renderPlayerBattlefield() {
playerBattlefieldPane.getChildren().clear();
playerBattlefieldPane.getChildren().addAll(playerBattlefieldData);
}
//Called at the end of turn, and when a card is played
public void createData() {
this.createHands();
this.createBattlefields();
}
//CREATE HANDS
private void createHands() {
this.createOpponentHand();
this.createPlayerHand();
}
//CREATE OPPONENT CARD BACKS
private List<Group> opponentHandData;
private void createOpponentHand() {
opponentHandData.clear();
//Opponent cards are rendered differently because the faces are not visible
double paneHeight = opponentHandPane.getHeight();
double paneWidth = opponentHandPane.getWidth();
int numCards = this.getOpponentCardCount();
int maxCards = Math.max(numCards, 8);
double cardWidth = paneWidth / maxCards;
int currentCard = 0;
while(currentCard < numCards) {
Group cardGroup = new Group();
cardGroup.setTranslateX(currentCard * (cardWidth*1.05)); //1.05 so there is space between cards
opponentHandData.add(cardGroup);
Rectangle cardBack = new Rectangle(0,0,cardWidth,paneHeight);
cardBack.setFill(Color.AQUAMARINE);
cardGroup.getChildren().add(cardBack);
currentCard++;
}
}
private int getOpponentCardCount() {
Player player = game.getLastPlayer();
Zone hand = (Zone)CoerceLuaToJava.coerce(player.data.get("hand"), Zone.class);
List<Card> cardsInHand = hand.getCards();
return cardsInHand.size();
}
//CREATE PLAYER HAND
private List<Group> playerHandData;
private void createPlayerHand() {
playerHandData.clear();
List<Card> cardsInHand = this.getCurrentPlayerHand();
int numCards = cardsInHand.size();
int cardIndex = 0;
for (Card card : cardsInHand) {
CardNode cardNode = new CardNode(playerHandPane, numCards, "testName", card, this);
Group cardGroup = cardNode.getCardGroup();
cardGroup.setAutoSizeChildren(true); //NEW
cardGroup.setId(String.format("card%d", card.getId()));
cardGroup.setTranslateX(cardIndex * cardNode.getWidth());
playerHandData.add(cardGroup);
cardIndex++;
}
}
private List<Card> getCurrentPlayerHand() {
Player player = game.getFirstPlayer();
Zone hand = (Zone)CoerceLuaToJava.coerce(player.data.get("hand"), Zone.class);
return hand.getCards();
}
//CREATE BATTLEFIELDS
private void createBattlefields() {
this.createOpponentBattlefield();
this.createPlayerBattlefield();
}
//CREATE OPPONENT BATTLEFIELD
private List<Group> opponentBattlefieldData;
private void createOpponentBattlefield() {
opponentBattlefieldData.clear();
List<Card> cardsInBattlefield = this.getBattlefield(game.getLastPlayer());
int numCards = cardsInBattlefield.size();
int cardIndex = 0;
for (Card card : cardsInBattlefield) {
CardNodeBattlefield cardNode = new CardNodeBattlefield(opponentBattlefieldPane, numCards, "testName", card, this, false);
Group cardGroup = cardNode.getCardGroup();
cardGroup.setAutoSizeChildren(true); //NEW
cardGroup.setId(String.format("card%d", card.getId()));
cardGroup.setTranslateX(cardIndex * cardNode.getWidth());
opponentBattlefieldData.add(cardGroup);
cardIndex++;
}
}
//CREATE PLAYER BATTLEFIELD
private List<Group> playerBattlefieldData;
private void createPlayerBattlefield() {
playerBattlefieldData.clear();
List<Card> cardsInBattlefield = this.getBattlefield(game.getFirstPlayer());
int numCards = cardsInBattlefield.size();
int cardIndex = 0;
for (Card card : cardsInBattlefield) {
CardNodeBattlefield cardNode = new CardNodeBattlefield(playerBattlefieldPane, numCards, "testName", card, this, true);
Group cardGroup = cardNode.getCardGroup();
cardGroup.setAutoSizeChildren(true); //NEW
cardGroup.setId(String.format("card%d", card.getId()));
cardGroup.setTranslateX(cardIndex * cardNode.getWidth());
playerBattlefieldData.add(cardGroup);
cardIndex++;
}
}
private List<Card> getBattlefield(Player player) {
Zone battlefield = (Zone)CoerceLuaToJava.coerce(player.data.get("battlefield"), Zone.class);
return battlefield.getCards();
}
//END TURN LABEL
@FXML
private Label turnLabel;
//ADVANCE TURNS
@FXML
private void handleTurnButtonAction(ActionEvent event) {
if (gameHasStarted == true) {
game.nextTurn();
//This is the AI doing the turn action
while (game.getCurrentPlayer() == game.getLastPlayer()) {
UsableAction action = opponent.getAction(game.getCurrentPlayer());
if (action == null) {
System.out.println("Warning: Opponent did not properly end turn");
break;
}
action.perform();
}
turnLabel.setText(String.format("Turn Number %d", game.getTurnNumber()));
//reload data at the start of a new turn
this.createData();
this.render();
}
}
//CHOICE BOX PANE
public void buildChoiceBoxPane(Card card, List<UsableAction> actionList) {
Pane choiceBoxPane = new Pane();
choiceBoxPane.setPrefHeight(367);
choiceBoxPane.setPrefWidth(550);
choiceBoxPane.setTranslateX(326);
choiceBoxPane.setTranslateY(150);
choiceBoxPane.setId("choiceBoxPane");
choiceBoxPane.getChildren().clear();
int numChoices = actionList.size();
double paneHeight = choiceBoxPane.getPrefHeight();
double paneWidth = choiceBoxPane.getPrefWidth();
double choiceBoxWidth = paneWidth / numChoices;
int actionIndex = 0;
for(UsableAction action : actionList) {
ChoiceBoxNode choiceBox = new ChoiceBoxNode(choiceBoxWidth, paneHeight, "testName", action, this);
Group choiceBoxGroup = choiceBox.getChoiceBoxGroup();
choiceBoxGroup.setTranslateX(actionIndex * choiceBoxWidth);
choiceBoxPane.getChildren().add(choiceBox.getChoiceBoxGroup());
actionIndex++;
}
anchorPane.getChildren().add(choiceBoxPane);
}
//TARGETING
public TargetAction nextAction;
public void performNextAction(Targetable target) {
nextAction.setTarget(target);
nextAction.perform();
this.createData();
this.render();
}
public void markTargets(List<Card> targets) {
List<Node> cardsInPlayerBattlefield = playerBattlefieldPane.getChildren();
List<Node> cardsInOpponentBattlefield = opponentBattlefieldPane.getChildren();
for (Card target : targets) {
for(Node node : cardsInPlayerBattlefield) {
if(node.getId().equals(String.format("card%d",target.getId())) == true) {
CardNodeBattlefield actionNode = (CardNodeBattlefield)node;
actionNode.createTargetButton();
}
}
for(Node node : cardsInOpponentBattlefield) {
if(node.getId().equals(String.format("card%d",target.getId())) == true) {
CardNodeBattlefield actionNode = (CardNodeBattlefield)node;
actionNode.createTargetButton();
}
}
}
}
//GAME STATE LABELS
@FXML
Label opponentLife;
@FXML
Label opponentCurrentMana;
@FXML
Label opponentTotalMana;
@FXML
Label opponentScrap;
@FXML
Label playerLife;
@FXML
Label playerCurrentMana;
@FXML
Label playerTotalMana;
@FXML
Label playerScrap;
private void updateGameLabels() {
Player opponent = game.getLastPlayer();
opponentLife.setText(String.format("%d",opponent.data.get("life").toint()));
opponentCurrentMana.setText(String.format("%d",opponent.data.get("mana").toint()));
opponentTotalMana.setText(String.format("%d",opponent.data.get("manaMax").toint()));
opponentScrap.setText(String.format("%d",opponent.data.get("scrap").toint()));
Player player = game.getFirstPlayer();
playerLife.setText(String.format("%d", player.data.get("life").toint()));
playerCurrentMana.setText(String.format("%d", player.data.get("mana").toint()));
playerTotalMana.setText(String.format("%d", player.data.get("manaMax").toint()));
playerScrap.setText(String.format("%d", player.data.get("scrap").toint()));
}
//BOILERPLATE
@Override
public void initialize(URL url, ResourceBundle rb) {
// TODO
}
//NOT YET USED
@FXML
private QuadCurve handGuide;
}
Finally, this is the FXML file that controls the layout for all the objects that are not created programmatically:
FXMLDocument.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.text.*?>
<?import javafx.scene.shape.*?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane fx:id="anchorPane" maxHeight="600" maxWidth="1200" minHeight="600" minWidth="1200" prefHeight="600.0" prefWidth="1200.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.cardshifter.fx.FXMLGameController">
<children>
<!-- Buttons -->
<Button fx:id="button" layoutX="1098.0" layoutY="530.0" onAction="#startGameButtonAction" text="Start Game" />
<Label fx:id="startGameLabel" layoutX="1098.0" layoutY="564.0" minHeight="16" minWidth="69" />
<Button layoutX="1105.0" layoutY="467.0" mnemonicParsing="false" onAction="#handleTurnButtonAction" text="End Turn" />
<Label fx:id="turnLabel" layoutX="1091.0" layoutY="500.0" text="Turn Number" />
<Button fx:id="newGameButton" layoutX="17.0" layoutY="24.0" mnemonicParsing="false" onAction="#newGameButtonAction" text="New Game" />
<!-- Hands -->
<Pane fx:id="playerHandPane" layoutX="14.0" layoutY="393.0" prefHeight="200.0" prefWidth="1066.0">
<children>
<QuadCurve fx:id="handGuide" controlY="-100.0" endX="100.0" fill="DODGERBLUE" layoutX="480.0" layoutY="200.0" opacity="0.0" startX="-100.0" stroke="BLACK" strokeType="INSIDE" />
</children>
</Pane>
<Pane fx:id="opponentHandPane" layoutX="153.0" layoutY="6.0" prefHeight="61.0" prefWidth="895.0" />
<!-- Battlefields -->
<Pane fx:id="playerBattlefieldPane" layoutX="47.0" layoutY="237.0" prefHeight="98.0" prefWidth="1001.0" />
<Pane fx:id="opponentBattlefieldPane" layoutX="47.0" layoutY="107.0" prefHeight="98.0" prefWidth="1001.0" />
<!-- Game State Labels -->
<Label layoutX="1111.0" layoutY="29.0" text="Opponent" />
<Label layoutX="1133.0" layoutY="51.0" text="Life" />
<Label layoutX="1101.0" layoutY="91.0" text="Current Mana" />
<Label layoutX="1107.0" layoutY="131.0" text="Total Mana" />
<Label fx:id="opponentLife" layoutX="1133.0" layoutY="67.0" prefHeight="16.0" prefWidth="35.0" text="#" textAlignment="CENTER" />
<Label fx:id="opponentCurrentMana" layoutX="1140.0" layoutY="107.0" text="#" />
<Label fx:id="opponentTotalMana" layoutX="1140.0" layoutY="148.0" text="#" />
<Label layoutX="1126.0" layoutY="263.0" text="Player" />
<Label layoutX="1133.0" layoutY="292.0" text="Life" />
<Label fx:id="playerLife" layoutX="1138.0" layoutY="308.0" text="#" />
<Label layoutX="1103.0" layoutY="327.0" text="Current Mana" />
<Label fx:id="playerCurrentMana" layoutX="1140.0" layoutY="343.0" text="#" />
<Label layoutX="1111.0" layoutY="366.0" text="Total Mana" />
<Label fx:id="playerTotalMana" layoutX="1141.0" layoutY="385.0" text="#" />
<Label layoutX="1127.0" layoutY="171.0" text="Scrap" />
<Label layoutX="1129.0" layoutY="407.0" text="Scrap" />
<Label fx:id="playerScrap" layoutX="1141.0" layoutY="423.0" text="#" />
<Label fx:id="opponentScrap" layoutX="1140.0" layoutY="187.0" text="#" />
</children>
</AnchorPane>
2 Answers 2
Your JavaFXGame
class can use javadoc
/**
* This class just loads the FXML document which initializes its DocumentController
*/
public class JavaFXGame extends Application {
The start
method can be shortened and cleaned up:
@Override
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("FXMLDocument.fxml"));
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
}
Even though the specification from the Application
class specifies that throws Exception
, you can declare it as the following:
public void start(Stage stage) throws IOException {
Specifying the exception more is allowed in an overridden method, it is also allowed to get rid of it completely (although that's not necessary here as that would require a try-catch inside the method). This doesn't break the contract.
You have a comment for main
which looks a bit like Javadoc, but please use real javadoc instead:
/**
* Main method
* @param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
Taking a look at just the first part of your other class and we can see that it is filled with unnecessary comments and other fluff:
//INITIAL GAME SETUP
private final CardshifterAI opponent = new CompleteIdiot();
//need a forward declaration so that this is global to the class
private Game game;
//hack to make the buttons work properly
private boolean gameHasStarted = false;
//I think this is a public constructor, this code initializes the Game
@FXML
Pane anchorPane;
public FXMLGameController() throws Exception {
this.initializeGame();
}
You have several unnecessary comments here (although I understand some of them are because you are new to the language).
private final CardshifterAI opponent = new CompleteIdiot();
private Game game;
private boolean gameHasStarted = false;
@FXML
Pane anchorPane;
public FXMLGameController() throws Exception {
this.initializeGame();
}
You seem to be very inconsistent in using private
on the fields. Remember to use private whenever possible.
It is standard practice in Java to keep all class-variables (a.k.a. fields) at the top of the class together. This makes a typical Java class have the order 1. fields 2. constructor 3. methods. 4. static-methods (where you place the static-methods can differ, but they should always be put together whenever there are any).
On some occasions you seem to be doing this:
if (gameHasStarted == false) {
// a lot of code...
}
// end of method
In such cases I personally prefer to return early to reduce indentation.
Also, you can use !gameHasStarted
instead of comparing with == false
. !
is the logical not-operator in Java.
if (!gameHasStarted) {
return;
}
// a lot of code...
This also applies to the handleTurnButtonAction
method, but there you can simply remove == true
as comparing with true is the same as returning the boolean directly.
startGameButtonAction
and newGameButtonAction
are way too similar to be two separate methods. Consider calling one from the other. Also ask yourself this question: Is startGame
button really needed? And/Or is the call to initializeGame
in the constructor really needed? Consider making it start a new game automatically on startup.
Either way, there's code duplication there that is easy to get rid of. (Extract function is one way, but as said, consider changing the user experience)
The Java conventions for spacing is like this:
while (currentCard < numCards) {
...
cardGroup.setTranslateX(currentCard * cardWidth * 1.05); // note that a set of parenthesis can be removed here
Rectangle cardBack = new Rectangle(0, 0, cardWidth, paneHeight);
createOpponentBattlefield
, createPlayerBattlefield
and createPlayerHand
is way too similar. As the game model seems to use a Zone
class, perhaps you should make a ZoneView
class that is responsible for displaying a Zone
.
There is also some duplicate code in markTargets
, but I believe that can be handled by using a ZoneView
class. Then you could call markTargets
on each ZoneView
.
Finally, I personally prefer a little bit more space when there is a whole bunch of annotations and fields together. Such as the following:
@FXML
Label opponentCurrentMana;
@FXML
Label opponentTotalMana;
@FXML
Label opponentScrap;
@FXML
Label playerLife;
@FXML
Label playerCurrentMana;
Alternatively, you can write the annotation on the same line as the field type and name.
@FXML Label opponentCurrentMana;
@FXML Label opponentTotalMana;
@FXML Label opponentScrap;
@FXML Label playerLife;
@FXML Label playerCurrentMana;
P.S. Don't forget private
.
Command line parsing
I suppose this has some unimplemented functionality:
CommandLineOptions options = new CommandLineOptions(); InputStream file = options.getScript() == null ? Game.class.getResourceAsStream("start.lua") : new FileInputStream(new File(options.getScript())); game = new Game(file, options.getRandom());
I would guess that options.getScript()
is always null, unless CommandLineOptions
has some magic in it.
The second line is too long. Having to scroll to the far right makes this hard to read.
Constant strings like "start.lua" should not be buried in the code, better to move them to the top into static constants. But actually, this bit doesn't belong to the game class.
Separation of responsibilities
Is it really the game's responsibility to deal with IO errors during startup? Not really. It's the responsibility of the launcher of the game. Move the all the launching (command line parsing, configuration) related stuff outside to a dedicated helper class.
Duplication
You really have too much duplicated logic. For example:
private void renderOpponentHand() { opponentHandPane.getChildren().clear(); opponentHandPane.getChildren().addAll(opponentHandData); } private void renderPlayerHand() { playerHandPane.getChildren().clear(); playerHandPane.getChildren().addAll(playerHandData); }
and
private void renderOpponentBattlefield() { opponentBattlefieldPane.getChildren().clear(); opponentBattlefieldPane.getChildren().addAll(opponentBattlefieldData); } private void renderPlayerBattlefield() { playerBattlefieldPane.getChildren().clear(); playerBattlefieldPane.getChildren().addAll(playerBattlefieldData); }
These call for a helper:
private void replacePaneData(Pane pane, List<Group> data) {
pane.getChildren().clear();
pane.getChildren().addAll(data);
}
This is just an example. Take a careful look through the entire code and make sure you extract all the duplicated elements to helper methods.
Declaration order
At first glance I thought this code won't compile, because early on, I saw these initializations:
this.playerBattlefieldData = new ArrayList<>(); this.opponentBattlefieldData = new ArrayList<>(); this.playerHandData = new ArrayList<>(); this.opponentHandData = new ArrayList<>();
... but not the variable declarations... I had to search through your code, and these variables are declared at various different places. This is confusing, and against the standard. This is the recommended declaration order:
- Class (static) variables. First the public class variables, then the protected, then package level (no access modifier), and then the private.
- Instance variables. First the public class variables, then the protected, then package level (no access modifier), and then the private.
- Constructors
- Methods
For more details, see: Checkstyle declaration order docs.
-
1\$\begingroup\$ Excellent answer overall. The
CommandLineOptions
thing is because bazola copied my code that was console-based, where I used a 3rd party library to populate the contents from theString[] args
. Bazola just needs to learn that he shouldn't do everything the same as I do :) \$\endgroup\$Simon Forsberg– Simon Forsberg2014年09月17日 12:03:46 +00:00Commented Sep 17, 2014 at 12:03
Explore related questions
See similar questions with these tags.